From d7958b384146e7ac0b2c9673eb4170aaba5c87d3 Mon Sep 17 00:00:00 2001 From: denshooter Date: Fri, 6 Mar 2026 00:02:52 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Hardcover=E2=86=92Directus=20book=20syn?= =?UTF-8?q?c=20+=20fix=20empty=20states=20for=20projects/books?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/n8n/hardcover/sync-books/route.ts | 125 ++++++++++++++++++++++ app/components/Projects.tsx | 4 + app/components/ReadBooks.tsx | 7 +- lib/directus.ts | 80 ++++++++++++++ messages/de.json | 3 +- messages/en.json | 3 +- 6 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 app/api/n8n/hardcover/sync-books/route.ts diff --git a/app/api/n8n/hardcover/sync-books/route.ts b/app/api/n8n/hardcover/sync-books/route.ts new file mode 100644 index 0000000..9742d14 --- /dev/null +++ b/app/api/n8n/hardcover/sync-books/route.ts @@ -0,0 +1,125 @@ +/** + * 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 }); +} diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx index 5d04ecd..80cecc5 100644 --- a/app/components/Projects.tsx +++ b/app/components/Projects.tsx @@ -74,6 +74,10 @@ const Projects = () => { )) + ) : projects.length === 0 ? ( +
+ No projects yet. +
) : ( projects.map((project) => ( { } if (reviews.length === 0) { - return null; // Hier kannst du temporär "Keine Bücher gefunden" reinschreiben zum Testen + return ( +
+ + {t("empty")} +
+ ); } const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW); diff --git a/lib/directus.ts b/lib/directus.ts index 2b698a5..429e429 100644 --- a/lib/directus.ts +++ b/lib/directus.ts @@ -993,3 +993,83 @@ export async function getSnippets(limit = 10, featured?: boolean): Promise { + const query = ` + query { + book_reviews( + filter: { hardcover_id: { _eq: "${hardcoverId}" } } + limit: 1 + ) { + id + } + } + `; + + try { + const result = await directusRequest<{ book_reviews: Array<{ id: string }> }>( + '', + { body: { query } } + ); + const item = result?.book_reviews?.[0]; + return item ?? null; + } catch { + return null; + } +} + +/** + * Create a new book review in the Directus book_reviews collection via REST API. + * Returns the created item id, or null on failure. + */ +export async function createBookReview( + data: BookReviewCreate +): Promise<{ id: string } | null> { + if (!DIRECTUS_TOKEN) return null; + + try { + const response = await fetch(`${DIRECTUS_URL}/items/book_reviews`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${DIRECTUS_TOKEN}`, + }, + body: JSON.stringify(data), + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + const text = await response.text(); + if (process.env.NODE_ENV === 'development') { + console.error(`Directus create book_review failed ${response.status}:`, text.slice(0, 200)); + } + return null; + } + + const result = await response.json(); + return result?.data?.id ? { id: result.data.id } : null; + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.error('createBookReview error:', error); + } + return null; + } +} diff --git a/messages/de.json b/messages/de.json index f759069..954cef7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -69,7 +69,8 @@ "title": "Gelesene Bücher", "finishedAt": "Beendet am", "showMore": "{count} weitere anzeigen", - "showLess": "Weniger anzeigen" + "showLess": "Weniger anzeigen", + "empty": "In Hardcover fertig gelesene Bücher erscheinen hier automatisch." }, "activity": { "idleStatus": "System im Leerlauf / Geist aktiv", diff --git a/messages/en.json b/messages/en.json index 7ef5bad..3472ffe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -70,7 +70,8 @@ "title": "Read", "finishedAt": "Finished", "showMore": "{count} more", - "showLess": "Show less" + "showLess": "Show less", + "empty": "Books finished in Hardcover will appear here automatically." }, "activity": { "idleStatus": "System Idle / Mind Active",