cards/apps/api/src/routes/cards.ts
Till 5f67bd9f3e Phase 3 follow-up: type-check + tests grün, ts-fsrs v5 API
- tsconfig.base.json: allowImportingTsExtensions + noEmit (.ts-Imports
  in dev, kein tsc-Output, vitest/bun/vite handhaben Build)
- ts-fsrs v5.3.2 API-Updates:
  - scheduler.next(card, now, grade) statt repeat(card, now)[rating].card
  - Grade-Type für RATING_TO_FSRS (excluded Manual)
  - learning_steps-Feld auf Review (Schema, Drizzle-Column, Adapter,
    DTO-Konverter, Tests)
- apps/web: extends .svelte-kit/tsconfig.json (SvelteKit-Empfehlung),
  test-Script mit --passWithNoTests
- apps/api: dropped types: ['bun-types'] (stale)
- pnpm-lock.yaml committed

Status:
- pnpm run type-check  4/4 packages grün (api, domain, web mit
  svelte-check 0 errors)
- pnpm run test  46 Tests grün (cards-domain: 27, apps/api: 19,
  apps/web: --passWithNoTests)
- pnpm install  136 packages, 8s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:41:04 +02:00

164 lines
4.8 KiB
TypeScript

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(),
};
}