diff --git a/apps/api/scripts/migrate-db-to-events.ts b/apps/api/scripts/migrate-db-to-events.ts new file mode 100644 index 0000000..2e1ca9f --- /dev/null +++ b/apps/api/scripts/migrate-db-to-events.ts @@ -0,0 +1,310 @@ +/** + * Migration-Skript für den Big-Bang-Cutover L-2 (2026-05-20). + * + * Liest pro User alle Decks + Cards + Reviews aus der Wordeck-DB und + * publiziert sie als event-sourced Append-Sequence an + * sync2.mana.how/sync/wordeck. Idempotent über `idempotencyKey = + * 'migration::'`. + * + * Verwendung (immer dry-run probieren bevor echter Run): + * + * pnpm tsx scripts/migrate-db-to-events.ts --dry-run [--user-id ] + * pnpm tsx scripts/migrate-db-to-events.ts --commit [--user-id ] + * + * Ohne --user-id wird über alle User iteriert. --dry-run gibt Counts + * aus ohne POST. --commit POSTet tatsächlich. + * + * Voraussetzungen: + * - DATABASE_URL gesetzt (Wordeck-DB) + * - MANA_SYNC_URL (default https://sync2.mana.how) + * - MANA_SERVICE_KEY (für service-side User-JWT-Mint oder per-User-Token) + * - User-JWTs: Skript ruft mana-auth-`POST /api/v1/service/mint-token` + * mit Service-Key + user_id für temporäre JWT (Service-Key-Pattern, + * siehe shared-auth) + * + * Encryption: Daten werden im **Plaintext** an sync2 gesendet — wir + * haben keinen User-Master-Key zur Migration-Zeit. Server speichert + * sie als wire-format string (NoOp-kompatibel). Wenn der User sich + * später einloggt + Vault-Key bootstrappt, kann ein Re-Encrypt-Pass + * folgen. Für den Big-Bang akzeptieren wir Plaintext-Migration, weil: + * - die Daten waren vorher schon in Postgres plaintext + * - der Sync-Server liegt in derselben Trust-Domain (Mac Mini) + * - User kann nach Login encrypted-Versionen drüberspielen + * + * **Status: Skript-Stub, ungetestet.** Vor echtem Run: + * 1. Code-Review durch Till + * 2. Snapshot von mana_sync_v2.wordeck.* + Wordeck-DB + * 3. Dry-run für 1-2 User + * 4. Manuelle Verifikation des sync2-States + * 5. Erst dann --commit + */ + +import { eq, sql } from 'drizzle-orm'; + +import { getDb } from '../src/db/connection.ts'; +import { cards, decks, reviews } from '../src/db/schema/index.ts'; + +const SYNC_URL = process.env.MANA_SYNC_URL ?? 'https://sync2.mana.how'; +const APP_ID = 'wordeck'; + +interface Args { + dryRun: boolean; + userId?: string; +} + +function parseArgs(): Args { + const args = process.argv.slice(2); + const dryRun = !args.includes('--commit'); + const uIdx = args.indexOf('--user-id'); + const userId = uIdx >= 0 ? args[uIdx + 1] : undefined; + return { dryRun, userId }; +} + +interface EventEnvelope { + eventId: string; + aggregateId: string; + appId: string; + eventType: string; + eventVersion: number; + occurredAt: string; + actor: { kind: 'migration'; principalId: string; displayName: string }; + attributedToUserId: string; + origin: 'migration'; + idempotencyKey: string; + payload: Record; +} + +function ulid(): string { + // Crypto-Random ULID-light für Migration. Echtes ulid-Format wäre + // schöner, aber für migration-events reicht ein eindeutiger String. + return ( + Math.floor(Date.now()).toString(36).padStart(8, '0').toUpperCase() + + crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase() + ); +} + +function envelopeFor( + userId: string, + aggregateId: string, + eventType: string, + idemSuffix: string, + payload: Record, + occurredAt: Date, +): EventEnvelope { + return { + eventId: ulid(), + aggregateId, + appId: APP_ID, + eventType, + eventVersion: 1, + occurredAt: occurredAt.toISOString(), + actor: { + kind: 'migration', + principalId: 'migration:wordeck:2026-05-20', + displayName: 'L-2 DB→Event-Sync Migration', + }, + attributedToUserId: userId, + origin: 'migration', + idempotencyKey: `migration:${idemSuffix}`, + payload, + }; +} + +async function mintToken(userId: string): Promise { + // Stub: Service-Key-basierter Mint. Implementation hängt davon ab, + // wie mana-auth das anbietet. Wenn nicht vorhanden, müssen wir + // einen anderen Pfad finden (z.B. sync2 mit Service-Key-Auth-Mode + // erweitern, was eigene Plattform-Arbeit ist). + throw new Error( + `mintToken stub: implementiere Service-Key-basierten JWT-Mint für ${userId}`, + ); +} + +async function postBatch(token: string, events: EventEnvelope[]): Promise { + const res = await fetch(`${SYNC_URL}/sync/${APP_ID}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ events }), + }); + if (!res.ok) { + throw new Error(`sync2 returned ${res.status}: ${await res.text()}`); + } +} + +async function migrateUser(userId: string, dryRun: boolean): Promise { + const db = getDb(); + const deckRows = await db.select().from(decks).where(eq(decks.userId, userId)); + const cardRows = await db.select().from(cards).where(eq(cards.userId, userId)); + const reviewRows = await db.select().from(reviews).where(eq(reviews.userId, userId)); + + const events: EventEnvelope[] = []; + + for (const d of deckRows) { + events.push( + envelopeFor( + userId, + `deck:${d.id}`, + 'DeckCreated', + `deck:${d.id}:created`, + { + deckId: d.id, + name: d.name, + description: d.description, + color: d.color, + category: d.category, + }, + d.createdAt, + ), + ); + if (d.fsrsSettings && Object.keys(d.fsrsSettings).length > 0) { + events.push( + envelopeFor( + userId, + `deck:${d.id}`, + 'DeckFsrsSettingsUpdated', + `deck:${d.id}:fsrs`, + { deckId: d.id, newSettingsJson: JSON.stringify(d.fsrsSettings) }, + d.updatedAt, + ), + ); + } + if (d.visibility !== 'private') { + events.push( + envelopeFor( + userId, + `deck:${d.id}`, + 'DeckPublished', + `deck:${d.id}:published`, + { deckId: d.id, visibility: d.visibility, license: 'CC-BY-4.0' }, + d.updatedAt, + ), + ); + } + if (d.archivedAt) { + events.push( + envelopeFor( + userId, + `deck:${d.id}`, + 'DeckArchived', + `deck:${d.id}:archived`, + { deckId: d.id }, + d.archivedAt, + ), + ); + } + } + + for (const card of cardRows) { + events.push( + envelopeFor( + userId, + `card:${card.id}`, + 'CardCreated', + `card:${card.id}:created`, + { + cardId: card.id, + deckId: card.deckId, + type: card.type, + fieldsJson: JSON.stringify(card.fields), + tags: [], + }, + card.createdAt, + ), + ); + } + + for (const r of reviewRows) { + const aggId = `review:${r.cardId}__${r.subIndex}`; + events.push( + envelopeFor( + userId, + aggId, + 'ReviewInitialized', + `review:${r.cardId}:${r.subIndex}:init`, + { + reviewId: aggId, + cardId: r.cardId, + subIndex: r.subIndex, + due: r.due.toISOString(), + }, + r.lastReview ?? r.due, + ), + ); + if (r.state !== 'new' || r.reps > 0) { + // Aktueller State als ReviewGraded mit "rating=good" Sentinel-Wert. + // FSRS-Felder werden 1:1 übernommen. Nach Migration kann der User + // weiter graden, neue Events stacken sich auf. + events.push( + envelopeFor( + userId, + aggId, + 'ReviewGraded', + `review:${r.cardId}:${r.subIndex}:state`, + { + reviewId: aggId, + rating: 'good', + newState: r.state, + newDue: r.due.toISOString(), + newStability: r.stability, + newDifficulty: r.difficulty, + newElapsedDays: r.elapsedDays, + newScheduledDays: r.scheduledDays, + newLearningSteps: r.learningSteps, + newReps: r.reps, + newLapses: r.lapses, + prevSnapshotJson: null, + }, + r.lastReview ?? r.due, + ), + ); + } + } + + console.log( + `[user ${userId}] decks=${deckRows.length} cards=${cardRows.length} reviews=${reviewRows.length} → ${events.length} events`, + ); + if (dryRun) return; + + const token = await mintToken(userId); + // Batches à 100 wegen sync2-server-limit + for (let i = 0; i < events.length; i += 100) { + await postBatch(token, events.slice(i, i + 100)); + } + console.log(`[user ${userId}] migration applied`); +} + +async function main(): Promise { + const { dryRun, userId } = parseArgs(); + console.log(`migration: dry-run=${dryRun} userId=${userId ?? '(all)'}`); + + const db = getDb(); + let userIds: string[]; + if (userId) { + userIds = [userId]; + } else { + const rows = await db.execute<{ user_id: string }>( + sql`SELECT DISTINCT user_id FROM wordeck.decks`, + ); + userIds = rows.rows.map((r) => r.user_id); + } + console.log(`migration: ${userIds.length} user(s)`); + + let ok = 0; + let fail = 0; + for (const uid of userIds) { + try { + await migrateUser(uid, dryRun); + ok++; + } catch (e) { + console.error(`[user ${uid}] FAILED:`, e instanceof Error ? e.message : e); + fail++; + } + } + console.log(`migration: ok=${ok} fail=${fail}`); + process.exit(fail > 0 ? 1 : 0); +} + +void main(); diff --git a/apps/api/src/routes/cards.ts b/apps/api/src/routes/cards.ts index 70ead3f..41f156a 100644 --- a/apps/api/src/routes/cards.ts +++ b/apps/api/src/routes/cards.ts @@ -1,179 +1,47 @@ -import { and, eq } from 'drizzle-orm'; +/** + * Cards-Routes — Big-Bang-Cutover L-2 (2026-05-20). + * + * Sämtliche CRUD-Operationen für Karten leben jetzt client-seitig über + * `@mana/event-sync` und sync2.mana.how. Diese Router-Stub existiert + * nur noch als Mount-Point, damit der Hono-App-Tree konsistent bleibt + * und etwaige Legacy-Calls einen klaren 410-Gone-Hinweis bekommen. + * + * Was hier gelöscht wurde: + * POST /api/v1/cards + * GET /api/v1/cards + * GET /api/v1/cards/hashes + * GET /api/v1/cards/:id + * PATCH /api/v1/cards/:id + * DELETE /api/v1/cards/:id + * + * Folgende Web-Helpers laufen jetzt lokal: + * - createCard, updateCard, deleteCard → emit CardCreated/… + * - listCards, getCard → sync.aggregateList('card') + * - listCardHashes → state.contentHash aus + * lokaler IndexedDB-Projektion + */ + import { Hono } from 'hono'; -import { - CardCreateSchema, - CardUpdateSchema, - cardContentHash, - subIndexCount, - subIndexCountForCloze, -} from '@wordeck/domain'; - -import { makeInitialReviewRows } from '../lib/reviews.ts'; -import { toCardDto } from '../lib/dto.ts'; - -import { getDb, type CardsDb } from '../db/connection.ts'; -import { cards, decks, reviews } from '../db/schema/index.ts'; +import type { CardsDb } from '../db/connection.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 GONE_BODY = { + error: 'gone', + humanMessage: + 'Diese Route ist nach dem event-sync-Cutover entfernt. Nutze @mana/event-sync — Doku unter mana/packages/event-sync/docs/anonymous.md.', +} as const; + +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'); - - // Text-abhängige Sub-Index-Counts (Cloze) vor dem Deck-Lookup - // auflösen — Validation-Errors bleiben 422 statt versehentlich - // auf 404 zu fallen. - let count: number; - if (parsed.data.type === 'cloze') { - count = subIndexCountForCloze(parsed.data.fields.text ?? ''); - if (count === 0) { - return c.json( - { error: 'invalid_input', issues: ['cloze.text contains no {{cN::…}} clusters'] }, - 422 - ); - } - } else { - count = subIndexCount(parsed.data.type); - } - - 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: count }, (_, i) => i); - const contentHash = await cardContentHash({ - type: parsed.data.type, - fields: parsed.data.fields, - }); - - 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, - contentHash, - createdAt: now, - updatedAt: now, - }) - .returning(); - - const initialReviews = makeInitialReviewRows({ userId, cardId, subIndices, now }); - if (initialReviews.length > 0) { - await tx.insert(reviews).values(initialReviews); - } - - return [card]; - }); - - return c.json(toCardDto(cardRow), 201); + r.all('*', (c) => { + c.header('Deprecation', 'true'); + c.header('Sunset', 'Sat, 20 Jun 2026 00:00:00 GMT'); + c.header('Link', '; rel="alternate"'); + return c.json(GONE_BODY, 410); }); - - 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 }); - }); - - /** - * Liefert nur die content_hash-Liste des Users — kompakter Pfad für - * den Anki-Re-Import-Dedupe. Frontend lädt das einmal und prüft pro - * Karte clientseitig, statt für jeden Insert einen Round-Trip zu - * machen. Karten ohne content_hash (Pre-Phase-9j) werden weggefiltert. - */ - r.get('/hashes', async (c) => { - const userId = c.get('userId'); - const rows = await dbOf() - .select({ contentHash: cards.contentHash }) - .from(cards) - .where(eq(cards.userId, userId)); - const hashes = rows - .map((r) => r.contentHash) - .filter((h): h is string => typeof h === 'string' && h.length > 0); - return c.json({ hashes, total: hashes.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 }), - 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; } diff --git a/apps/api/src/routes/decks-generate.ts b/apps/api/src/routes/decks-generate.ts index 536951b..225752b 100644 --- a/apps/api/src/routes/decks-generate.ts +++ b/apps/api/src/routes/decks-generate.ts @@ -1,20 +1,12 @@ -import { eq } from 'drizzle-orm'; import { Hono } from 'hono'; import { z } from 'zod'; -import { cardContentHash, subIndexCount } from '@wordeck/domain'; - -import { makeInitialReviewRows } from '../lib/reviews.ts'; - -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 { rateLimit, userKey } from '../middleware/rate-limit.ts'; -import { ulid } from '../lib/ulid.ts'; import { chatJson } from '../services/llm-client.ts'; import { fetchUrlContent } from '../lib/url-fetch.ts'; -export type GenerateDeps = { db?: CardsDb }; +export type GenerateDeps = Record; // Was die LLM zurückgeben muss. zod-strict damit Halluzinationen // (extra Felder, falsche Types) hart abgelehnt werden. @@ -33,71 +25,12 @@ export const GeneratedDeckSchema = z.object({ }); export type GeneratedDeck = z.infer; -export async function insertGeneratedDeck( - db: CardsDb, - userId: string, - generated: GeneratedDeck, - descriptionFallback: string, -) { - const deckId = ulid(); - const now = new Date(); - const cardRowsInsert = await Promise.all( - generated.cards.map(async (gc) => { - const id = ulid(); - const fields = { front: gc.front, back: gc.back }; - const contentHash = await cardContentHash({ type: 'basic', fields }); - return { id, fields, contentHash }; - }) - ); - - await db.transaction(async (tx) => { - await tx.insert(decks).values({ - id: deckId, - userId, - name: generated.deck_name, - description: generated.deck_description ?? descriptionFallback, - color: '#7c3aed', - visibility: 'private', - fsrsSettings: {}, - createdAt: now, - updatedAt: now, - }); - - for (const cr of cardRowsInsert) { - await tx.insert(cards).values({ - id: cr.id, - deckId, - userId, - type: 'basic', - fields: cr.fields, - contentHash: cr.contentHash, - createdAt: now, - updatedAt: now, - }); - const subIndices = Array.from({ length: subIndexCount('basic') }, (_, i) => i); - const initial = makeInitialReviewRows({ userId, cardId: cr.id, subIndices, now }); - await tx.insert(reviews).values(initial); - } - }); - - const [row] = await db.select().from(decks).where(eq(decks.id, deckId)).limit(1); - return { - deck: row - ? { - id: row.id, - name: row.name, - description: row.description, - color: row.color, - visibility: row.visibility, - fsrs_settings: row.fsrsSettings, - user_id: row.userId, - created_at: row.createdAt.toISOString(), - updated_at: row.updatedAt.toISOString(), - } - : null, - cards_created: cardRowsInsert.length, - }; -} +/** + * Big-Bang-Migration L-2 (2026-05-20): Server schreibt nicht mehr in + * decks/cards/reviews — der Client emittet Events nach event-sync. Hier + * returnt der Endpoint nur den LLM-Vorschlag, der Client baut daraus + * DeckCreated + N×CardCreated. + */ const GenerateInputSchema = z.object({ prompt: z.string().min(3).max(500), @@ -125,16 +58,14 @@ Regeln: - KEIN HTML, KEIN Code-Fence, KEINE Erklärung außerhalb des JSON. - Wenn die User-Anfrage in einer bestimmten Sprache ist, antworte in derselben Sprache.`; -export function decksGenerateRouter(deps: GenerateDeps = {}): Hono<{ Variables: AuthVars }> { +export function decksGenerateRouter(_deps: GenerateDeps = {}): Hono<{ Variables: AuthVars }> { const r = new Hono<{ Variables: AuthVars }>(); - const dbOf = () => deps.db ?? getDb(); r.use('*', authMiddleware); // 10/min per User — LLM-Call ist teuer (mana-llm-Credits). r.use('/', rateLimit({ scope: 'decks.generate', windowMs: 60_000, max: 10, keyOf: userKey })); r.post('/', async (c) => { - const userId = c.get('userId'); const body = await c.req.json().catch(() => null); const parsed = GenerateInputSchema.safeParse(body); if (!parsed.success) { @@ -184,13 +115,25 @@ ${parsed.data.prompt}`; return c.json({ error: 'llm_call_failed', detail: msg }, 502); } - const result = await insertGeneratedDeck( - dbOf(), - userId, - generated, - `KI-generiert: ${parsed.data.prompt}`, + // Client-Side-Cutover (L-2): nur Vorschlag returnen, Client emittet + // die Events selbst in event-sync. So bleibt die Plattform local- + // first konsistent (kein Server-Write neben dem Event-Stream). + return c.json( + { + suggestion: { + deck: { + name: generated.deck_name, + description: generated.deck_description ?? `KI-generiert: ${parsed.data.prompt}`, + color: '#7c3aed', + }, + cards: generated.cards.map((c) => ({ + type: 'basic' as const, + fields: { front: c.front, back: c.back }, + })), + }, + }, + 200, ); - return c.json(result, 201); }); return r; diff --git a/apps/api/src/routes/decks.ts b/apps/api/src/routes/decks.ts index 8f4e3db..b9e4513 100644 --- a/apps/api/src/routes/decks.ts +++ b/apps/api/src/routes/decks.ts @@ -1,16 +1,39 @@ -import { and, eq, isNotNull, isNull, ne } from 'drizzle-orm'; +/** + * Decks-Routes — Big-Bang-Cutover L-2 (2026-05-20). + * + * Server-CRUD entfernt — alle user-owned Deck-Operationen laufen jetzt + * über `@mana/event-sync` und sync2.mana.how. Diese Datei hostet nur + * noch zwei Read-Only-Lookups, die quer-tabellig (Marketplace) oder + * deck-skopiert (Distractors) sind und vor der Migration nicht über + * den client-side EventLog beantwortet werden können. + * + * Was hier gelöscht wurde (alle 410 Gone bzw. nicht mehr erreichbar): + * POST /api/v1/decks + * GET /api/v1/decks + * GET /api/v1/decks/:id + * PATCH /api/v1/decks/:id + * DELETE /api/v1/decks/:id + * POST /api/v1/decks/:id/duplicate + * + * Verbleibend: + * GET /api/v1/decks/:id/marketplace-source (cross-tabular) + * GET /api/v1/decks/:deckId/distractors (random sample aus Deck) + * + * Distractors-Endpoint quert die User-cards-Tabelle, die nach dem + * vollständigen Cutover ebenfalls leer ist. Sobald keine Legacy-User- + * Decks mehr existieren, kann der Distractors-Endpoint auf einen + * client-side-Pfad umgestellt werden (Card-Liste aus event-sync, + * Random-Sample im Browser). + */ + +import { and, eq, ne } from 'drizzle-orm'; import { sql } from 'drizzle-orm'; import { Hono } from 'hono'; -import { DeckCreateSchema, DeckUpdateSchema } from '@wordeck/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 }> { @@ -19,113 +42,6 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }> 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'); @@ -145,59 +61,10 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }> 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, - 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). + * 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'); @@ -224,18 +91,20 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }> : and(eq(cards.deckId, deckId), eq(cards.userId, userId)); const rows = await dbOf() - .select({ - value: sql`jsonb_extract_path_text(${cards.fields}, ${fieldParam})`, - }) + .select({ fields: cards.fields }) .from(cards) .where(where) - .orderBy(sql`RANDOM()`) + .orderBy(sql`random()`) .limit(count); - const distractors = rows - .map((r) => r.value) - .filter((v): v is string => typeof v === 'string' && v.length > 0); - + const distractors: string[] = []; + for (const r of rows) { + const f = r.fields as Record; + const v = f[fieldParam]; + if (typeof v === 'string' && v.length > 0) { + distractors.push(v); + } + } return c.json({ distractors }); }); diff --git a/apps/api/src/routes/dsgvo.ts b/apps/api/src/routes/dsgvo.ts index fa64074..c874888 100644 --- a/apps/api/src/routes/dsgvo.ts +++ b/apps/api/src/routes/dsgvo.ts @@ -20,6 +20,18 @@ export type DsgvoDeps = { db?: CardsDb }; * Sammelt alle User-Daten für einen DSGVO-Export. Gemeinsam genutzt * vom Service-Key-Endpoint (mana-admin-Fanout) und dem User-Self- * Export aus /api/v1/me. + * + * **Big-Bang-Cutover L-2 Notiz (2026-05-20):** Nach dem Event-Sync- + * Cutover werden neue User-Daten *nicht mehr in diese DB-Tabellen + * geschrieben*. Sie leben in `mana_sync_v2.wordeck.*` auf + * sync2.mana.how. Der vollständige DSGVO-Export für einen User braucht + * deshalb beide Quellen: + * 1. Diese Funktion → Legacy-DB-Daten (Pre-Cutover-Decks/Cards/Reviews) + * 2. sync2 `GET /export/full` mit User-JWT → alle event-sourced-Daten + * + * mana-admin's DSGVO-Fanout muss beide Pfade aufrufen + zusammenführen. + * Nach Migration aller Legacy-Daten (siehe scripts/migrate-db-to-events.ts) + * sind die DB-Tabellen leer, dieser Pfad liefert leeres Bundle. */ export async function buildUserExport(db: CardsDb, userId: string) { const [ diff --git a/apps/api/src/routes/reviews.ts b/apps/api/src/routes/reviews.ts index 962b54b..d300e99 100644 --- a/apps/api/src/routes/reviews.ts +++ b/apps/api/src/routes/reviews.ts @@ -1,213 +1,38 @@ -import { and, asc, eq, lte } from 'drizzle-orm'; +/** + * Reviews-Routes — Big-Bang-Cutover L-2 (2026-05-20). + * + * FSRS rechnet jetzt client-side über @wordeck/domain.gradeReview; + * jeder Grade-Schritt emittet `ReviewGraded` in event-sync. Damit ist + * der Server-side FSRS-Scheduler obsolet. Stub bleibt als Mount-Point + * für klare 410-Gone-Antworten auf Legacy-Aufrufe. + * + * Was hier gelöscht wurde: + * GET /api/v1/reviews/due + * POST /api/v1/reviews/:cardId/:subIndex/grade + * POST /api/v1/reviews/:cardId/:subIndex/undo + */ + import { Hono } from 'hono'; -import { - GradeReviewInputSchema, - gradeReview, - type Review as DomainReview, -} from '@wordeck/domain'; - -import { getDb, type CardsDb } from '../db/connection.ts'; -import { cards, decks, reviews } from '../db/schema/index.ts'; +import type { CardsDb } from '../db/connection.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; export type ReviewsDeps = { db?: CardsDb }; -export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVars }> { +const GONE_BODY = { + error: 'gone', + humanMessage: + 'Diese Route ist nach dem event-sync-Cutover entfernt. Nutze @mana/event-sync + @wordeck/domain.gradeReview client-side.', +} as const; + +export function reviewsRouter(_deps: ReviewsDeps = {}): Hono<{ Variables: AuthVars }> { const r = new Hono<{ Variables: AuthVars }>(); - const dbOf = () => deps.db ?? getDb(); - r.use('*', authMiddleware); - - /** - * Hot Path: alle Reviews holen, deren due <= now ist. - * Optional auf ein Deck eingeschränkt. Default-Limit: 100. - * - * Index: `reviews_user_due_idx` deckt diese Query ab. - */ - r.get('/due', async (c) => { - const userId = c.get('userId'); - const deckId = c.req.query('deck_id'); - const recovery = c.req.query('recovery') === 'true'; - const limit = recovery ? 25 : Math.min(Number(c.req.query('limit') ?? 100), 500); - const now = new Date(); - - const conditions = [eq(reviews.userId, userId), lte(reviews.due, now)]; - const orderBy = recovery ? asc(reviews.stability) : asc(reviews.due); - - if (deckId) { - // Wenn deck_id angegeben, joinen wir auf cards.deck_id. - const rows = await dbOf() - .select({ - review: reviews, - card: { id: cards.id, deckId: cards.deckId, type: cards.type, fields: cards.fields }, - }) - .from(reviews) - .innerJoin(cards, eq(cards.id, reviews.cardId)) - .where(and(...conditions, eq(cards.deckId, deckId))) - .orderBy(orderBy) - .limit(limit); - return c.json({ - reviews: rows.map((r) => ({ ...toReviewDto(r.review), card: r.card })), - total: rows.length, - }); - } - - const rows = await dbOf() - .select() - .from(reviews) - .where(and(...conditions)) - .orderBy(orderBy) - .limit(limit); - return c.json({ reviews: rows.map(toReviewDto), total: rows.length }); + r.all('*', (c) => { + c.header('Deprecation', 'true'); + c.header('Sunset', 'Sat, 20 Jun 2026 00:00:00 GMT'); + c.header('Link', '; rel="alternate"'); + return c.json(GONE_BODY, 410); }); - - /** - * User bewertet eine Karte. FSRS rechnet nächste due-time aus, - * wir schreiben das Update zurück. - */ - r.post('/:cardId/:subIndex/grade', async (c) => { - const userId = c.get('userId'); - const cardId = c.req.param('cardId'); - const subIndex = Number(c.req.param('subIndex')); - if (!Number.isInteger(subIndex) || subIndex < 0) { - return c.json({ error: 'invalid_sub_index' }, 422); - } - - const body = await c.req.json().catch(() => null); - const parsed = GradeReviewInputSchema.safeParse({ - ...((body as object) ?? {}), - card_id: cardId, - sub_index: subIndex, - }); - if (!parsed.success) { - return c.json( - { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, - 422 - ); - } - - // Aktuellen Review-State holen + Deck-FSRS-Settings für den Scheduler. - const [hit] = await dbOf() - .select({ - review: reviews, - deck: { fsrsSettings: decks.fsrsSettings }, - }) - .from(reviews) - .innerJoin(cards, eq(cards.id, reviews.cardId)) - .innerJoin(decks, eq(decks.id, cards.deckId)) - .where( - and( - eq(reviews.cardId, cardId), - eq(reviews.subIndex, subIndex), - eq(reviews.userId, userId) - ) - ) - .limit(1); - - if (!hit) return c.json({ error: 'not_found' }, 404); - - const reviewedAt = parsed.data.reviewed_at ? new Date(parsed.data.reviewed_at) : new Date(); - const currentDomain: DomainReview = toReviewDto(hit.review); - const next = gradeReview( - currentDomain, - parsed.data.rating, - reviewedAt, - (hit.deck.fsrsSettings as object) ?? {} - ); - - // Snapshot des alten Zustands vor dem Überschreiben — ermöglicht Undo. - const snapshot = toReviewDto(hit.review) as Record; - - const [updated] = await dbOf() - .update(reviews) - .set({ - due: new Date(next.due), - stability: next.stability, - difficulty: next.difficulty, - elapsedDays: next.elapsed_days, - scheduledDays: next.scheduled_days, - learningSteps: next.learning_steps, - reps: next.reps, - lapses: next.lapses, - state: next.state, - lastReview: next.last_review ? new Date(next.last_review) : null, - prevSnapshot: snapshot, - }) - .where( - and( - eq(reviews.cardId, cardId), - eq(reviews.subIndex, subIndex), - eq(reviews.userId, userId) - ) - ) - .returning(); - - return c.json(toReviewDto(updated)); - }); - - /** - * Macht die letzte Bewertung rückgängig — stellt prevSnapshot wieder her - * und löscht den Snapshot danach. Nur einmal pro Bewertung möglich. - */ - r.post('/:cardId/:subIndex/undo', async (c) => { - const userId = c.get('userId'); - const cardId = c.req.param('cardId'); - const subIndex = Number(c.req.param('subIndex')); - - if (!Number.isInteger(subIndex) || subIndex < 0) { - return c.json({ error: 'invalid_sub_index' }, 422); - } - - const [hit] = await dbOf() - .select() - .from(reviews) - .where(and(eq(reviews.cardId, cardId), eq(reviews.subIndex, subIndex), eq(reviews.userId, userId))) - .limit(1); - - if (!hit) return c.json({ error: 'not_found' }, 404); - if (!hit.prevSnapshot) return c.json({ error: 'no_snapshot' }, 409); - - const snap = hit.prevSnapshot as Record; - - const [restored] = await dbOf() - .update(reviews) - .set({ - due: new Date(snap['due'] as string), - stability: snap['stability'] as number, - difficulty: snap['difficulty'] as number, - elapsedDays: snap['elapsed_days'] as number, - scheduledDays: snap['scheduled_days'] as number, - learningSteps: snap['learning_steps'] as number, - reps: snap['reps'] as number, - lapses: snap['lapses'] as number, - state: snap['state'] as typeof reviews.$inferSelect['state'], - lastReview: snap['last_review'] ? new Date(snap['last_review'] as string) : null, - prevSnapshot: null, - }) - .where(and(eq(reviews.cardId, cardId), eq(reviews.subIndex, subIndex), eq(reviews.userId, userId))) - .returning(); - - return c.json(toReviewDto(restored)); - }); - return r; } - -function toReviewDto(row: typeof reviews.$inferSelect): DomainReview { - return { - card_id: row.cardId, - sub_index: row.subIndex, - user_id: row.userId, - due: row.due.toISOString(), - stability: row.stability, - difficulty: row.difficulty, - elapsed_days: row.elapsedDays, - scheduled_days: row.scheduledDays, - learning_steps: row.learningSteps, - reps: row.reps, - lapses: row.lapses, - state: row.state, - last_review: row.lastReview ? row.lastReview.toISOString() : null, - }; -} diff --git a/apps/api/tests/cards.test.ts b/apps/api/tests/cards.test.ts index 4231589..58261fe 100644 --- a/apps/api/tests/cards.test.ts +++ b/apps/api/tests/cards.test.ts @@ -1,164 +1,36 @@ -import { describe, it, expect } from 'vitest'; +/** + * Cards-Routes — Post-Cutover-Smoke (L-2, 2026-05-20). + * + * Alle Cards-CRUD-Routes returnen 410 Gone mit Deprecation-Header. + */ + +import { describe, expect, it } from 'vitest'; import { Hono } from 'hono'; import { cardsRouter } from '../src/routes/cards.ts'; -import type { CardsDb } from '../src/db/connection.ts'; -/** - * Routen-Tests ohne echte DB. Drizzle-Aufrufe werden durch eine - * minimale Stub-DB ersetzt, die nur die Validations-Pfade abdeckt. - */ - -function buildApp() { +function withRouter() { const app = new Hono(); - const stub = { - select: () => ({ - from: () => ({ - where: () => ({ limit: () => [] }), - }), - }), - }; - app.route('/api/v1/cards', cardsRouter({ db: stub as unknown as CardsDb })); - return { app }; + app.route('/api/v1/cards', cardsRouter({ db: undefined })); + return app; } -describe('cardsRouter — auth-gate', () => { - it('GET ohne X-User-Id ist 401', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards'); - expect(res.status).toBe(401); +describe('cardsRouter — post-cutover 410 Gone', () => { + it('GET / mit Auth-Stub → 410 + Deprecation-Header', async () => { + const app = withRouter(); + const res = await app.request('/api/v1/cards', { + headers: { 'X-User-Id': 'u1' }, + }); + expect(res.status).toBe(410); + expect(res.headers.get('deprecation')).toBe('true'); + expect(res.headers.get('sunset')).toContain('Jun 2026'); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('gone'); }); - it('GET /hashes ohne X-User-Id ist 401', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards/hashes'); + it('POST / ohne Auth → 401 (Middleware first)', async () => { + const app = withRouter(); + const res = await app.request('/api/v1/cards', { method: 'POST' }); expect(res.status).toBe(401); }); }); - -describe('cardsRouter — Input-Validation', () => { - it('POST mit leerem Body ist 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: '{}', - }); - expect(res.status).toBe(422); - }); - - it('POST mit basic-Card ohne back-Feld ist 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deck_id: 'd-1', - type: 'basic', - fields: { front: 'Q' }, - }), - }); - expect(res.status).toBe(422); - }); - - it('POST mit unknown CardType ist 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deck_id: 'd-1', - type: 'audio', - fields: { audio_ref: 'x' }, - }), - }); - expect(res.status).toBe(422); - }); - - it('POST mit image-occlusion ist 422 (CardType nicht mehr akzeptiert)', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deck_id: 'd-1', - type: 'image-occlusion', - fields: { image_ref: 'm1', mask_regions: '[]' }, - }), - }); - expect(res.status).toBe(422); - }); - - it('POST mit cloze-Card ohne text-Feld ist 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deck_id: 'd-1', - type: 'cloze', - fields: {}, - }), - }); - expect(res.status).toBe(422); - }); - - it('POST mit cloze-Card aber Text ohne Cluster ist 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deck_id: 'd-1', - type: 'cloze', - fields: { text: 'plain text without any {{cN::…}} markup' }, - }), - }); - expect(res.status).toBe(422); - const body = (await res.json()) as { error: string; issues: string[] }; - expect(body.issues[0]).toMatch(/cloze\.text/); - }); - - it('POST mit gültiger cloze-Card erreicht Deck-Lookup (404 bei stub)', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deck_id: 'd-1', - type: 'cloze', - fields: { text: 'Capital of {{c1::France}} is {{c2::Paris}}.' }, - }), - }); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('deck_not_found'); - }); - - it('PATCH mit extra prop ist 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards/c-1', { - method: 'PATCH', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ fields: { front: 'X' }, leak: 'bad' }), - }); - expect(res.status).toBe(422); - }); - - it('POST mit gültigem basic-Card erreicht Deck-Lookup (404 bei stub)', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/cards', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deck_id: 'd-1', - type: 'basic', - fields: { front: 'Q', back: 'A' }, - }), - }); - // Stub-DB gibt empty array → Deck-Not-Found-Pfad - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('deck_not_found'); - }); -}); diff --git a/apps/api/tests/decks.test.ts b/apps/api/tests/decks.test.ts index 65b82f8..4373b93 100644 --- a/apps/api/tests/decks.test.ts +++ b/apps/api/tests/decks.test.ts @@ -1,164 +1,44 @@ -import { describe, it, expect } from 'vitest'; +/** + * Decks-Routes — Post-Cutover-Smokes (L-2, 2026-05-20). + * + * Nach dem event-sync-Cutover existieren nur noch zwei Read-Only- + * Endpoints: marketplace-source-Lookup + Distractors-Sample. Beide + * brauchen Auth. + */ + +import { describe, expect, it } from 'vitest'; import { Hono } from 'hono'; import { decksRouter } from '../src/routes/decks.ts'; -import type { CardsDb } from '../src/db/connection.ts'; -/** - * Routen-Tests ohne echte DB. Wir mocken die paar Drizzle-Methoden, die - * der Decks-Router nutzt, mit einem winzigen In-Memory-Store. - * - * Echte Integrations-Tests (gegen postgres/pg-mem) folgen in einer späteren - * Phase, wenn die Test-Infra steht. - */ - -type Row = { - id: string; - userId: string; - name: string; - description: string | null; - color: string | null; - visibility: 'private' | 'space' | 'public'; - fsrsSettings: unknown; - contentHash: string | null; - createdAt: Date; - updatedAt: Date; -}; - -function makeFakeDb() { - const store = new Map(); - - const fakeDb = { - insert: (_table: unknown) => ({ - values: (vals: Partial & { id: string; userId: string }) => ({ - returning: async () => { - const row: Row = { - id: vals.id, - userId: vals.userId, - name: vals.name ?? '', - description: vals.description ?? null, - color: vals.color ?? null, - visibility: vals.visibility ?? 'private', - fsrsSettings: vals.fsrsSettings ?? {}, - contentHash: vals.contentHash ?? null, - createdAt: vals.createdAt ?? new Date(), - updatedAt: vals.updatedAt ?? new Date(), - }; - store.set(row.id, row); - return [row]; - }, - }), - }), - select: () => ({ - from: (_table: unknown) => ({ - where: (filter: { userId?: string; id?: string }) => { - const items = Array.from(store.values()).filter((r) => { - if (filter.userId && r.userId !== filter.userId) return false; - if (filter.id && r.id !== filter.id) return false; - return true; - }); - return Object.assign(items as Row[] | Promise, { - limit: (_n: number) => items.slice(0, _n), - }); - }, - }), - }), - update: (_table: unknown) => ({ - set: (patch: Partial) => ({ - where: (filter: { userId: string; id: string }) => ({ - returning: async () => { - const existing = store.get(filter.id); - if (!existing || existing.userId !== filter.userId) return []; - const updated = { ...existing, ...patch, updatedAt: new Date() }; - store.set(updated.id, updated); - return [updated]; - }, - }), - }), - }), - delete: (_table: unknown) => ({ - where: (filter: { userId: string; id: string }) => ({ - returning: async () => { - const existing = store.get(filter.id); - if (!existing || existing.userId !== filter.userId) return []; - store.delete(filter.id); - return [{ id: filter.id }]; - }, - }), - }), - }; - - // Drizzle's eq/and dont actually pass a function-based filter; the fake-db - // shim above doesn't match real Drizzle wire-shape. So we override the - // interpretation: the test patches eq/and via a simpler comparator. - // For now this fake-DB is sufficient ONLY if the routes' .where()-args - // arrive as plain { userId, id } objects. They don't — they arrive as - // Drizzle-SQL builders. So tests below are scoped to validation/auth paths, - // not full CRUD. - - return { fakeDb, store }; -} - -function buildApp() { - const { fakeDb } = makeFakeDb(); - // Cast — the fakeDb is intentionally minimal and not a full CardsDb. +function withRouter() { const app = new Hono(); - app.route('/api/v1/decks', decksRouter({ db: fakeDb as unknown as CardsDb })); - return { app }; + app.route('/api/v1/decks', decksRouter({ db: undefined })); + return app; } -describe('decksRouter — auth-gate', () => { - it('rejects requests without X-User-Id with 401', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/decks'); +describe('decksRouter — post-cutover', () => { + it('marketplace-source ohne Auth → 401', async () => { + const app = withRouter(); + const res = await app.request('/api/v1/decks/abc/marketplace-source'); expect(res.status).toBe(401); }); - it('lets through with X-User-Id (no DB call)', async () => { - const { app } = buildApp(); - // POST with invalid input should reach the validation step, not 401. + it('distractors ohne Auth → 401', async () => { + const app = withRouter(); + const res = await app.request('/api/v1/decks/abc/distractors'); + expect(res.status).toBe(401); + }); + + it('CRUD-Routes existieren nicht mehr — POST mit Auth-Stub → 404 (no route matches)', async () => { + const app = withRouter(); const res = await app.request('/api/v1/decks', { method: 'POST', - headers: { - 'X-User-Id': 'u-1', - 'Content-Type': 'application/json', - }, - body: '{}', + headers: { 'Content-Type': 'application/json', 'X-User-Id': 'u1' }, + body: JSON.stringify({ name: 'foo' }), }); - expect(res.status).toBe(422); - }); -}); - -describe('decksRouter — input validation', () => { - it('POST with empty body is 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/decks', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: '{}', - }); - expect(res.status).toBe(422); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('invalid_input'); - }); - - it('POST with bad color is 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/decks', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'D', color: 'red' }), - }); - expect(res.status).toBe(422); - }); - - it('PATCH with extra prop is 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/decks/d-1', { - method: 'PATCH', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'X', leak: 'bad' }), - }); - expect(res.status).toBe(422); + // Mit Auth-Stub matched die Auth-Middleware, aber keine Route. + // Hono Default ohne Route: 404. + expect([404, 405]).toContain(res.status); }); }); diff --git a/apps/api/tests/reviews.test.ts b/apps/api/tests/reviews.test.ts index 60956c3..c82fb02 100644 --- a/apps/api/tests/reviews.test.ts +++ b/apps/api/tests/reviews.test.ts @@ -1,89 +1,38 @@ -import { describe, it, expect } from 'vitest'; +/** + * Reviews-Routes — Post-Cutover-Smoke (L-2, 2026-05-20). + * + * Alle Reviews-Routes returnen 410 Gone — FSRS-Compute läuft jetzt + * client-side via @wordeck/domain. + */ + +import { describe, expect, it } from 'vitest'; import { Hono } from 'hono'; import { reviewsRouter } from '../src/routes/reviews.ts'; -import type { CardsDb } from '../src/db/connection.ts'; -function buildApp() { - const stub = { - select: () => ({ - from: () => ({ - innerJoin: () => ({ - innerJoin: () => ({ - where: () => ({ limit: () => [] }), - }), - where: () => ({ - orderBy: () => ({ limit: () => [] }), - }), - }), - where: () => ({ - orderBy: () => ({ limit: () => [] }), - }), - }), - }), - }; +function withRouter() { const app = new Hono(); - app.route('/api/v1/reviews', reviewsRouter({ db: stub as unknown as CardsDb })); - return { app }; + app.route('/api/v1/reviews', reviewsRouter({ db: undefined })); + return app; } -describe('reviewsRouter — auth-gate', () => { - it('GET /due ohne X-User-Id ist 401', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/reviews/due'); - expect(res.status).toBe(401); +describe('reviewsRouter — post-cutover 410 Gone', () => { + it('GET /due mit Auth-Stub → 410', async () => { + const app = withRouter(); + const res = await app.request('/api/v1/reviews/due', { + headers: { 'X-User-Id': 'u1' }, + }); + expect(res.status).toBe(410); + expect(res.headers.get('deprecation')).toBe('true'); }); - it('POST /grade ohne X-User-Id ist 401', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/reviews/c-1/0/grade', { + it('POST grade → 410', async () => { + const app = withRouter(); + const res = await app.request('/api/v1/reviews/card-1/0/grade', { method: 'POST', + headers: { 'X-User-Id': 'u1', 'Content-Type': 'application/json' }, body: JSON.stringify({ rating: 'good' }), }); - expect(res.status).toBe(401); - }); -}); - -describe('reviewsRouter — Input-Validation', () => { - it('POST mit invalid sub_index ist 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/reviews/c-1/-1/grade', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ rating: 'good' }), - }); - expect(res.status).toBe(422); - }); - - it('POST ohne rating ist 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/reviews/c-1/0/grade', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: '{}', - }); - expect(res.status).toBe(422); - }); - - it('POST mit unknown rating ist 422', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/reviews/c-1/0/grade', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ rating: 'maybe' }), - }); - expect(res.status).toBe(422); - }); - - it('POST mit gültigem rating erreicht Lookup (404 bei stub)', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/reviews/c-1/0/grade', { - method: 'POST', - headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, - body: JSON.stringify({ rating: 'good' }), - }); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('not_found'); + expect(res.status).toBe(410); }); }); diff --git a/apps/web/src/lib/api/decks.ts b/apps/web/src/lib/api/decks.ts index 2b15c52..894dd67 100644 --- a/apps/web/src/lib/api/decks.ts +++ b/apps/web/src/lib/api/decks.ts @@ -142,21 +142,38 @@ export function duplicateDeck(id: string) { } /** - * AI-Generate-Endpoints bleiben HTTP — Tier-gated über mana-credits, - * Server hat Prompt-Templates + LLM-Provider-Routing. Result wird heute - * direkt als Deck+Cards persistiert vom Server. Follow-up-Task: Result - * als Event-Burst zum Client schicken, Client emittet lokal. + * AI-Generate-Endpoint nach Big-Bang-Cutover (L-2): Server liefert nur + * den LLM-Vorschlag (Deck-Metadaten + Karten-Inhalte), der Client + * emittet die Events lokal in event-sync. Tier-Gating + Rate-Limit + * bleibt server-seitig (LLM-Aufruf ist teuer). */ -export function generateDeck(input: { +export async function generateDeck(input: { prompt: string; language?: string; count?: number; url?: string; -}) { - return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/generate', { - method: 'POST', - body: input, +}): Promise<{ deck: Deck; cards_created: number }> { + const res = await api<{ + suggestion: { + deck: { name: string; description: string; color: string }; + cards: Array<{ type: 'basic'; fields: { front: string; back: string } }>; + }; + }>('/api/v1/decks/generate', { method: 'POST', body: input }); + + const created = await createDeck({ + name: res.suggestion.deck.name, + description: res.suggestion.deck.description, + color: res.suggestion.deck.color, }); + const { createCard } = await import('./cards.ts'); + for (const card of res.suggestion.cards) { + await createCard({ + deck_id: created.id, + type: card.type, + fields: card.fields, + }); + } + return { deck: created, cards_created: res.suggestion.cards.length }; } export function fetchDistractors(