/** * 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 * 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 }); }