Deck schema, API routes, and SvelteKit UI for creating and browsing decks (DeckStack component, inline creation, floating nav). Production compose updated with PUBLIC_AUTH_WEB_URL so cards-web redirects to auth.mana.how for login/register instead of the raw API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
244 lines
7.5 KiB
TypeScript
244 lines
7.5 KiB
TypeScript
import { and, eq, isNotNull, isNull, 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, publicDecks } 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 archivedParam = c.req.query('archived');
|
|
const conditions = [eq(decks.userId, userId)];
|
|
if (forkedFromMarketplace === 'true') {
|
|
conditions.push(isNotNull(decks.forkedFromMarketplaceDeckId));
|
|
}
|
|
// archived=true → nur archivierte; default → nur aktive
|
|
if (archivedParam === 'true') {
|
|
conditions.push(isNotNull(decks.archivedAt));
|
|
} else {
|
|
conditions.push(isNull(decks.archivedAt));
|
|
}
|
|
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 now = new Date();
|
|
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,
|
|
}),
|
|
...(parsed.data.archived === true && { archivedAt: now }),
|
|
...(parsed.data.archived === false && { archivedAt: null }),
|
|
updatedAt: now,
|
|
})
|
|
.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 });
|
|
});
|
|
|
|
/** Gibt den Marketplace-Slug zurück, aus dem dieses Deck geforkt wurde (oder null). */
|
|
r.get('/:id/marketplace-source', async (c) => {
|
|
const userId = c.get('userId');
|
|
const id = c.req.param('id');
|
|
const [row] = await dbOf()
|
|
.select({ forkedId: decks.forkedFromMarketplaceDeckId })
|
|
.from(decks)
|
|
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
|
|
.limit(1);
|
|
if (!row) return c.json({ error: 'not_found' }, 404);
|
|
if (!row.forkedId) return c.json(null);
|
|
const [mp] = await dbOf()
|
|
.select({ slug: publicDecks.slug })
|
|
.from(publicDecks)
|
|
.where(eq(publicDecks.id, row.forkedId))
|
|
.limit(1);
|
|
return c.json(mp ? { slug: mp.slug } : null);
|
|
});
|
|
|
|
/** Dupliziert ein Deck (neue IDs, kein FSRS-Verlauf, kein Marketplace-Pointer). */
|
|
r.post('/:id/duplicate', async (c) => {
|
|
const userId = c.get('userId');
|
|
const id = c.req.param('id');
|
|
const db = dbOf();
|
|
const [source] = await db
|
|
.select()
|
|
.from(decks)
|
|
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
|
|
.limit(1);
|
|
if (!source) return c.json({ error: 'not_found' }, 404);
|
|
const sourceCards = await db
|
|
.select()
|
|
.from(cards)
|
|
.where(and(eq(cards.deckId, id), eq(cards.userId, userId)));
|
|
const newId = ulid();
|
|
const now = new Date();
|
|
const [newDeck] = await db
|
|
.insert(decks)
|
|
.values({
|
|
id: newId,
|
|
userId,
|
|
name: `${source.name} (Kopie)`,
|
|
description: source.description,
|
|
color: source.color,
|
|
category: source.category,
|
|
visibility: 'private',
|
|
fsrsSettings: source.fsrsSettings,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.returning();
|
|
if (sourceCards.length > 0) {
|
|
await db.insert(cards).values(
|
|
sourceCards.map((card) => ({
|
|
id: ulid(),
|
|
deckId: newId,
|
|
userId,
|
|
type: card.type,
|
|
fields: card.fields as Record<string, string>,
|
|
mediaRefs: card.mediaRefs,
|
|
contentHash: card.contentHash,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})),
|
|
);
|
|
}
|
|
return c.json(toDeckDto(newDeck), 201);
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
}
|