- `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>
165 lines
5.2 KiB
TypeScript
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;
|
|
}
|