import { and, eq, gte, isNotNull, lte, sql } from 'drizzle-orm'; import { Hono } from 'hono'; import { getDb, type CardsDb } from '../db/connection.ts'; import { cards, decks, importJobs, mediaFiles, reviews } from '../db/schema/index.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; import { getStorage } from '../services/storage.ts'; import { buildUserExport } from './dsgvo.ts'; export type MeDeps = { db?: CardsDb }; /** * User-Self-Service-Endpunkte. Auth: User-JWT/Dev-Stub. Identisch in * Wirkung zum Service-Key-DSGVO-Pfad, aber gegen die eigene User-ID * gated — der User braucht keinen mana-admin-Detour, um seine eigenen * Daten zu sehen oder zu löschen. */ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { const r = new Hono<{ Variables: AuthVars }>(); const dbOf = () => deps.db ?? getDb(); r.use('*', authMiddleware); /** Voll-Export der eigenen Daten als JSON. */ r.get('/export', async (c) => { const userId = c.get('userId'); return c.json(await buildUserExport(dbOf(), userId)); }); /** * Statistik-Snapshot für die Account-/Stats-Page. Nicht-cached, alle * Aggregate per Query. Ein einzelner Aufruf reicht für die /stats-UI. * * `reviewed_per_day` ist ein 7-Tage-Fenster (heute + 6 zurück), basierend * auf `reviews.last_review` — nicht study_sessions, weil die aktuell * nicht befüllt werden (Schema-Slot existiert, Writer kommt mit dem * Session-Tracker in einer späteren Phase). */ r.get('/stats', async (c) => { const userId = c.get('userId'); const db = dbOf(); const now = new Date(); const thirtyAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const [deckCountRow] = await db .select({ n: sql`count(*)::int` }) .from(decks) .where(eq(decks.userId, userId)); const [cardCountRow] = await db .select({ n: sql`count(*)::int` }) .from(cards) .where(eq(cards.userId, userId)); const [reviewCountRow] = await db .select({ n: sql`count(*)::int` }) .from(reviews) .where(eq(reviews.userId, userId)); const [dueCountRow] = await db .select({ n: sql`count(*)::int` }) .from(reviews) .where(and(eq(reviews.userId, userId), lte(reviews.due, now))); const stateRows = await db .select({ state: reviews.state, n: sql`count(*)::int` }) .from(reviews) .where(eq(reviews.userId, userId)) .groupBy(reviews.state); const stateCounts: Record = { new: 0, learning: 0, review: 0, relearning: 0, }; for (const row of stateRows) stateCounts[row.state] = row.n; // Reviews pro Tag — gruppiert auf UTC-Tag. const dayRows = await db .select({ day: sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`, n: sql`count(*)::int`, }) .from(reviews) .where( and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, thirtyAgo)) ) .groupBy(sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`); const byDay = new Map(dayRows.map((r) => [r.day, r.n])); const reviewed7 = Array.from({ length: 7 }, (_, i) => { const d = new Date(Date.now() - (6 - i) * 24 * 60 * 60 * 1000); const key = d.toISOString().slice(0, 10); return { day: key, n: byDay.get(key) ?? 0 }; }); // Streak: rückwärts ab heute (UTC) bis zum ersten Tag ohne Reviews. let streak = 0; for (let i = 0; i < 30; i++) { const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); const key = d.toISOString().slice(0, 10); if ((byDay.get(key) ?? 0) > 0) streak++; else break; } return c.json({ user_id: userId, generated_at: now.toISOString(), total_decks: deckCountRow?.n ?? 0, total_cards: cardCountRow?.n ?? 0, total_reviews: reviewCountRow?.n ?? 0, due_now: dueCountRow?.n ?? 0, state_counts: stateCounts, reviewed_per_day: reviewed7, streak_days: streak, }); }); /** * Vollständige Löschung der eigenen Cards-Daten. Identisch zur * Service-Key-Variante in /dsgvo/delete, aber für den eingeloggten * User selbst — ohne den Umweg über mana-admin. */ r.post('/delete', async (c) => { const userId = c.get('userId'); const db = dbOf(); const [deletedDecks, deletedImports, deletedMediaFiles] = 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 }); const dm = await tx .delete(mediaFiles) .where(eq(mediaFiles.userId, userId)) .returning({ id: mediaFiles.id }); return [dd, di, dm]; } ); let storageObjectsDeleted = 0; try { storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`); } catch (err) { console.warn('[me/delete] storage sweep failed:', err); } return c.json({ deleted: true, user_id: userId, counts: { decks: deletedDecks.length, import_jobs: deletedImports.length, media_files: deletedMediaFiles.length, storage_objects: storageObjectsDeleted, }, }); }); return r; }