wordeck/apps/api/src/routes/decks.ts
Till JS c39bacc971 refactor(api): DTO-Helper extrahieren + N+1 in marketplace/decks beheben
- `lib/dto.ts`: `toDeckDto` und `toCardDto` aus routes/decks.ts und
  routes/cards.ts extrahiert — testbar, zentrale Output-Shape-Doku
- `lib/marketplace/dto.ts`: `toPublicDeckDto`, `toOwnerDto`, `toVersionDto`
  aus routes/marketplace/decks.ts extrahiert
- `GET /:slug` in marketplace/decks.ts: Version + Owner parallel per
  `Promise.all` statt sequenziell (2 RTT → 1 RTT)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:30:29 +02:00

165 lines
5.2 KiB
TypeScript

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<string | null>`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;
}