import { and, eq } from 'drizzle-orm'; import { Hono } from 'hono'; import { CardCreateSchema, CardUpdateSchema, newReview, subIndexCount } from '@cards/domain'; import { getDb, type CardsDb } from '../db/connection.ts'; import { cards, decks, reviews } from '../db/schema/index.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; import { ulid } from '../lib/ulid.ts'; export type CardsDeps = { db?: CardsDb }; export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> { const r = new Hono<{ Variables: AuthVars }>(); const dbOf = () => deps.db ?? getDb(); r.use('*', authMiddleware); /** * Karte erstellen + automatisch initiale Reviews anlegen. * * Pro Card-Type werden N `(card_id, sub_index)`-Reviews angelegt * (basic = 1, basic-reverse = 2). Alles in einer Transaktion. */ r.post('/', async (c) => { const body = await c.req.json().catch(() => null); const parsed = CardCreateSchema.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 [deck] = await dbOf() .select({ id: decks.id, userId: decks.userId }) .from(decks) .where(eq(decks.id, parsed.data.deck_id)) .limit(1); if (!deck) return c.json({ error: 'deck_not_found' }, 404); if (deck.userId !== userId) return c.json({ error: 'deck_not_owned' }, 403); const cardId = ulid(); const now = new Date(); const subIndices = Array.from({ length: subIndexCount(parsed.data.type) }, (_, i) => i); const [cardRow] = await dbOf().transaction(async (tx) => { const [card] = await tx .insert(cards) .values({ id: cardId, deckId: parsed.data.deck_id, userId, type: parsed.data.type, fields: parsed.data.fields, mediaRefs: parsed.data.media_refs ?? [], createdAt: now, updatedAt: now, }) .returning(); const initialReviews = subIndices.map((subIndex) => { const r = newReview({ userId, cardId, subIndex, now }); return { cardId: r.card_id, subIndex: r.sub_index, userId: r.user_id, due: new Date(r.due), stability: r.stability, difficulty: r.difficulty, elapsedDays: r.elapsed_days, scheduledDays: r.scheduled_days, learningSteps: r.learning_steps, reps: r.reps, lapses: r.lapses, state: r.state, lastReview: r.last_review ? new Date(r.last_review) : null, }; }); if (initialReviews.length > 0) { await tx.insert(reviews).values(initialReviews); } return [card]; }); return c.json(toCardDto(cardRow), 201); }); r.get('/', async (c) => { const userId = c.get('userId'); const deckId = c.req.query('deck_id'); const conditions = deckId ? and(eq(cards.userId, userId), eq(cards.deckId, deckId)) : eq(cards.userId, userId); const rows = await dbOf().select().from(cards).where(conditions); return c.json({ cards: rows.map(toCardDto), 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(cards) .where(and(eq(cards.id, id), eq(cards.userId, userId))) .limit(1); if (!row) return c.json({ error: 'not_found' }, 404); return c.json(toCardDto(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 = CardUpdateSchema.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(cards) .set({ ...(parsed.data.fields !== undefined && { fields: parsed.data.fields }), ...(parsed.data.media_refs !== undefined && { mediaRefs: parsed.data.media_refs }), updatedAt: new Date(), }) .where(and(eq(cards.id, id), eq(cards.userId, userId))) .returning(); if (!row) return c.json({ error: 'not_found' }, 404); return c.json(toCardDto(row)); }); r.delete('/:id', async (c) => { const userId = c.get('userId'); const id = c.req.param('id'); const result = await dbOf() .delete(cards) .where(and(eq(cards.id, id), eq(cards.userId, userId))) .returning({ id: cards.id }); if (result.length === 0) return c.json({ error: 'not_found' }, 404); // reviews kaskadiert per onDelete: 'cascade' in der Schema-Definition. return c.json({ deleted: id }); }); return r; } function toCardDto(row: typeof cards.$inferSelect) { return { id: row.id, deck_id: row.deckId, user_id: row.userId, type: row.type, fields: row.fields, media_refs: row.mediaRefs ?? [], content_hash: row.contentHash, created_at: row.createdAt.toISOString(), updated_at: row.updatedAt.toISOString(), }; }