import { and, eq, isNotNull, ne } from 'drizzle-orm'; import { sql } from 'drizzle-orm'; import { Hono } from 'hono'; import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain'; import { getDb, type CardsDb } from '../db/connection.ts'; import { cards, decks } from '../db/schema/index.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; import { toDeckDto } from '../lib/dto.ts'; import { ulid } from '../lib/ulid.ts'; /** Optional injectable DB für Tests. */ export type DecksDeps = { db?: CardsDb }; export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }> { const r = new Hono<{ Variables: AuthVars }>(); const dbOf = () => deps.db ?? getDb(); r.use('*', authMiddleware); r.post('/', async (c) => { const body = await c.req.json().catch(() => null); const parsed = DeckCreateSchema.safeParse(body); if (!parsed.success) { return c.json( { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, 422 ); } const userId = c.get('userId'); const id = ulid(); const now = new Date(); const [row] = await dbOf() .insert(decks) .values({ id, userId, name: parsed.data.name, description: parsed.data.description, color: parsed.data.color, category: parsed.data.category, visibility: parsed.data.visibility ?? 'private', fsrsSettings: parsed.data.fsrs_settings ?? {}, createdAt: now, updatedAt: now, }) .returning(); return c.json(toDeckDto(row), 201); }); r.get('/', async (c) => { const userId = c.get('userId'); const forkedFromMarketplace = c.req.query('forked_from_marketplace'); const conditions = [eq(decks.userId, userId)]; if (forkedFromMarketplace === 'true') { conditions.push(isNotNull(decks.forkedFromMarketplaceDeckId)); } const rows = await dbOf() .select() .from(decks) .where(and(...conditions)); return c.json({ decks: rows.map(toDeckDto), total: rows.length }); }); r.get('/:id', async (c) => { const userId = c.get('userId'); const id = c.req.param('id'); const [row] = await dbOf() .select() .from(decks) .where(and(eq(decks.id, id), eq(decks.userId, userId))) .limit(1); if (!row) return c.json({ error: 'not_found' }, 404); return c.json(toDeckDto(row)); }); r.patch('/:id', async (c) => { const userId = c.get('userId'); const id = c.req.param('id'); const body = await c.req.json().catch(() => null); const parsed = DeckUpdateSchema.safeParse(body); if (!parsed.success) { return c.json( { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, 422 ); } const [row] = await dbOf() .update(decks) .set({ ...(parsed.data.name !== undefined && { name: parsed.data.name }), ...(parsed.data.description !== undefined && { description: parsed.data.description }), ...(parsed.data.color !== undefined && { color: parsed.data.color }), ...(parsed.data.category !== undefined && { category: parsed.data.category }), ...(parsed.data.visibility !== undefined && { visibility: parsed.data.visibility }), ...(parsed.data.fsrs_settings !== undefined && { fsrsSettings: parsed.data.fsrs_settings, }), updatedAt: new Date(), }) .where(and(eq(decks.id, id), eq(decks.userId, userId))) .returning(); if (!row) return c.json({ error: 'not_found' }, 404); return c.json(toDeckDto(row)); }); r.delete('/:id', async (c) => { const userId = c.get('userId'); const id = c.req.param('id'); const result = await dbOf() .delete(decks) .where(and(eq(decks.id, id), eq(decks.userId, userId))) .returning({ id: decks.id }); if (result.length === 0) return c.json({ error: 'not_found' }, 404); return c.json({ deleted: id }); }); /** * Liefert N zufällige Feldwerte aus anderen Karten desselben Decks — * als Distractors für multiple-choice-Karten. * `field` muss in der Allowlist sein (kein freier SQL-Zugriff). */ r.get('/:deckId/distractors', async (c) => { const userId = c.get('userId'); const deckId = c.req.param('deckId'); const cardId = c.req.query('card_id') ?? ''; const countRaw = parseInt(c.req.query('count') ?? '3', 10); const count = isNaN(countRaw) ? 3 : Math.min(10, Math.max(1, countRaw)); const fieldParam = c.req.query('field') ?? 'back'; const ALLOWED_FIELDS = new Set(['front', 'back', 'answer', 'question']); if (!ALLOWED_FIELDS.has(fieldParam)) { return c.json({ error: 'invalid_field' }, 422); } const [deck] = await dbOf() .select({ id: decks.id }) .from(decks) .where(and(eq(decks.id, deckId), eq(decks.userId, userId))) .limit(1); if (!deck) return c.json({ error: 'deck_not_found' }, 404); const where = cardId ? and(eq(cards.deckId, deckId), eq(cards.userId, userId), ne(cards.id, cardId)) : and(eq(cards.deckId, deckId), eq(cards.userId, userId)); const rows = await dbOf() .select({ value: sql`jsonb_extract_path_text(${cards.fields}, ${fieldParam})`, }) .from(cards) .where(where) .orderBy(sql`RANDOM()`) .limit(count); const distractors = rows .map((r) => r.value) .filter((v): v is string => typeof v === 'string' && v.length > 0); return c.json({ distractors }); }); return r; }