- 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>
164 lines
4.8 KiB
TypeScript
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(),
|
|
};
|
|
}
|