import { eq } from 'drizzle-orm'; import { Hono } from 'hono'; import { getDb, type CardsDb } from '../db/connection.ts'; import { cards, decks, importJobs, mediaRefs, reviews, studySessions, tags, } from '../db/schema/index.ts'; import { serviceKeyAuth } from '../middleware/service-key.ts'; export type DsgvoDeps = { db?: CardsDb }; /** * DSGVO-Endpunkte. Aufgerufen von mana-admin im Verein-DSGVO- * Fan-Out (Auskunft Art. 15/20 + Löschung Art. 17). * * Auth: Service-Key (`X-Service-Key` muss `CARDS_DSGVO_SERVICE_KEY` * matchen). Phase F-1: ersetzt durch mana-auth-Service-Key-Lookup. */ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono { const r = new Hono(); const dbOf = () => deps.db ?? getDb(); r.use('*', serviceKeyAuth({ envVar: 'CARDS_DSGVO_SERVICE_KEY' })); /** * Voll-Export aller Cards-Daten eines Users. Liefert serialisier- * bares JSON. mana-admin packt das mit den Antworten anderer Apps * in einen ZIP für den User. */ r.get('/export', async (c) => { const userId = c.req.query('user_id'); if (!userId) return c.json({ error: 'missing_user_id' }, 400); const db = dbOf(); const [decksRows, cardsRows, reviewsRows, sessionsRows, tagsRows, mediaRows, importsRows] = await Promise.all([ db.select().from(decks).where(eq(decks.userId, userId)), db.select().from(cards).where(eq(cards.userId, userId)), db.select().from(reviews).where(eq(reviews.userId, userId)), db.select().from(studySessions).where(eq(studySessions.userId, userId)), db.select().from(tags).where(eq(tags.userId, userId)), db.select().from(mediaRefs).where(eq(mediaRefs.userId, userId)), db.select().from(importJobs).where(eq(importJobs.userId, userId)), ]); return c.json({ user_id: userId, exported_at: new Date().toISOString(), app: 'cards', app_version: process.env.CARDS_API_VERSION ?? '0.0.0', data: { decks: decksRows.map((d) => ({ ...d, createdAt: d.createdAt.toISOString(), updatedAt: d.updatedAt.toISOString() })), cards: cardsRows.map((x) => ({ ...x, createdAt: x.createdAt.toISOString(), updatedAt: x.updatedAt.toISOString(), })), reviews: reviewsRows.map((r) => ({ ...r, due: r.due.toISOString(), lastReview: r.lastReview ? r.lastReview.toISOString() : null, })), study_sessions: sessionsRows.map((s) => ({ ...s, startedAt: s.startedAt.toISOString(), finishedAt: s.finishedAt ? s.finishedAt.toISOString() : null, })), tags: tagsRows.map((t) => ({ ...t, createdAt: t.createdAt.toISOString() })), media_refs: mediaRows.map((m) => ({ ...m, createdAt: m.createdAt.toISOString() })), import_jobs: importsRows.map((j) => ({ ...j, createdAt: j.createdAt.toISOString(), finishedAt: j.finishedAt ? j.finishedAt.toISOString() : null, })), }, }); }); /** * Vollständige Löschung aller Cards-Daten eines Users. FK-Cascades * räumen automatisch: * decks → cards → reviews * cards → media_refs * cards → card_tags * decks → tags * decks → study_sessions * Verbleibend: import_jobs (eigene Tabelle ohne FK) — wird separat gelöscht. */ r.post('/delete', async (c) => { const body = await c.req.json().catch(() => null); const userId = (body as { user_id?: string } | null)?.user_id; if (!userId) return c.json({ error: 'missing_user_id' }, 400); const db = dbOf(); const [deletedDecks, deletedImports] = await db.transaction(async (tx) => { const dd = await tx.delete(decks).where(eq(decks.userId, userId)).returning({ id: decks.id }); const di = await tx .delete(importJobs) .where(eq(importJobs.userId, userId)) .returning({ id: importJobs.id }); return [dd, di]; }); return c.json({ deleted: true, user_id: userId, counts: { decks: deletedDecks.length, import_jobs: deletedImports.length, }, }); }); return r; }