- Add POST /api/n8n/hardcover/sync-books — n8n calls this after detecting
finished books in Hardcover. Authenticates via N8N_SECRET_TOKEN/N8N_API_KEY,
deduplicates by hardcover_id, creates new book_reviews entries in Directus.
- Add getBookReviewByHardcoverId() + createBookReview() to lib/directus.ts.
Check uses GraphQL filter; create uses Directus REST POST /items/book_reviews.
- ReadBooks: replace silent return null with a visible empty state so the
section stays visible with a hint until the n8n sync populates it.
- Projects: add "No projects yet." placeholder instead of blank grid when
both Directus and PostgreSQL return no data.
- Add home.about.readBooks.empty i18n key (EN + DE).
n8n workflow setup:
Schedule → HTTP Hardcover GraphQL (books_read) → Code (transform) →
POST /api/n8n/hardcover/sync-books with array of { hardcover_id, title,
author, image, rating, finished_at }
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
3.9 KiB
TypeScript
126 lines
3.9 KiB
TypeScript
/**
|
||
* POST /api/n8n/hardcover/sync-books
|
||
*
|
||
* Called by an n8n workflow whenever books are finished in Hardcover.
|
||
* Creates new entries in the Directus book_reviews collection.
|
||
* Deduplicates by hardcover_id — safe to call repeatedly.
|
||
*
|
||
* n8n Workflow setup:
|
||
* 1. Schedule Trigger (every hour)
|
||
* 2. HTTP Request → Hardcover GraphQL (query: me { books_read(limit: 20) { ... } })
|
||
* 3. Code Node → transform to array of HardcoverBook objects
|
||
* 4. HTTP Request → POST https://dk0.dev/api/n8n/hardcover/sync-books
|
||
* Headers: Authorization: Bearer <N8N_SECRET_TOKEN>
|
||
* Body: [{ hardcover_id, title, author, image, rating, finished_at }, ...]
|
||
*
|
||
* Expected body shape (array or single object):
|
||
* {
|
||
* hardcover_id: string | number // Hardcover book ID, used for deduplication
|
||
* title: string
|
||
* author: string
|
||
* image?: string // Cover image URL
|
||
* rating?: number // 1–5
|
||
* finished_at?: string // ISO date string
|
||
* }
|
||
*/
|
||
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { getBookReviewByHardcoverId, createBookReview } from '@/lib/directus';
|
||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||
|
||
export const runtime = 'nodejs';
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
interface HardcoverBook {
|
||
hardcover_id: string | number;
|
||
title: string;
|
||
author: string;
|
||
image?: string;
|
||
rating?: number;
|
||
finished_at?: string;
|
||
}
|
||
|
||
export async function POST(request: NextRequest) {
|
||
// Auth: require N8N_SECRET_TOKEN or N8N_API_KEY
|
||
const authHeader = request.headers.get('Authorization');
|
||
const apiKeyHeader = request.headers.get('X-API-Key');
|
||
const validToken = process.env.N8N_SECRET_TOKEN;
|
||
const validApiKey = process.env.N8N_API_KEY;
|
||
|
||
const isAuthenticated =
|
||
(validToken && authHeader === `Bearer ${validToken}`) ||
|
||
(validApiKey && apiKeyHeader === validApiKey);
|
||
|
||
if (!isAuthenticated) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
// Rate limit: max 10 sync requests per minute
|
||
const ip = getClientIp(request);
|
||
if (!checkRateLimit(ip, 10, 60000, 'hardcover-sync')) {
|
||
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||
}
|
||
|
||
let books: HardcoverBook[];
|
||
try {
|
||
const body = await request.json();
|
||
books = Array.isArray(body) ? body : [body];
|
||
} catch {
|
||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||
}
|
||
|
||
if (books.length === 0) {
|
||
return NextResponse.json({ success: true, created: 0, skipped: 0, errors: 0 });
|
||
}
|
||
|
||
const results = {
|
||
created: 0,
|
||
skipped: 0,
|
||
errors: 0,
|
||
details: [] as string[],
|
||
};
|
||
|
||
for (const book of books) {
|
||
if (!book.title || !book.author) {
|
||
results.errors++;
|
||
results.details.push(`Skipped (missing title/author): ${JSON.stringify(book).slice(0, 80)}`);
|
||
continue;
|
||
}
|
||
|
||
const hardcoverId = String(book.hardcover_id);
|
||
|
||
// Deduplication: skip if already in Directus
|
||
const existing = await getBookReviewByHardcoverId(hardcoverId);
|
||
if (existing) {
|
||
results.skipped++;
|
||
results.details.push(`Skipped (exists): "${book.title}"`);
|
||
continue;
|
||
}
|
||
|
||
// Create new entry in Directus
|
||
const created = await createBookReview({
|
||
hardcover_id: hardcoverId,
|
||
book_title: book.title,
|
||
book_author: book.author,
|
||
book_image: book.image,
|
||
rating: book.rating,
|
||
finished_at: book.finished_at,
|
||
status: 'published',
|
||
});
|
||
|
||
if (created) {
|
||
results.created++;
|
||
results.details.push(`Created: "${book.title}" → id=${created.id}`);
|
||
} else {
|
||
results.errors++;
|
||
results.details.push(`Error creating: "${book.title}" (Directus unavailable or token missing)`);
|
||
}
|
||
}
|
||
|
||
if (process.env.NODE_ENV === 'development') {
|
||
console.log('[sync-books]', results);
|
||
}
|
||
|
||
return NextResponse.json({ success: true, source: 'directus', ...results });
|
||
}
|