feat(wordeck): Big-Bang-Cutover L-2 — Server-CRUD raus, alles auf event-sync
Some checks are pending
CI / validate (push) Waiting to run

L-2 von mana/docs/playbooks/LOCAL_FIRST_LOGIN_OPTIONAL.md:

apps/api:
- routes/decks.ts: CRUD-Routes gelöscht. Verbleibend nur Read-Only:
  GET /:id/marketplace-source + GET /:deckId/distractors
- routes/cards.ts: alle CRUD-Routes → 410 Gone mit Deprecation/Sunset
  Header (Sunset 2026-06-20). Sub-Pfade weren von Hono auf das *-Handler
  geleitet, die alle 410 zurückgeben
- routes/reviews.ts: alle Routes → 410 Gone, FSRS-Compute ist jetzt
  client-side via @wordeck/domain.gradeReview
- routes/decks-generate.ts: returnt nur noch LLM-Vorschlag
  ({ suggestion: { deck, cards } }), Server schreibt NICHTS mehr in
  decks/cards/reviews. Client emittet Events lokal in event-sync
- routes/dsgvo.ts: Doku-Block: nach Big-Bang sind neue User-Daten in
  sync2 mana_sync_v2.wordeck.*, nicht mehr hier. mana-admin-Fanout
  muss beide Quellen abfragen
- scripts/migrate-db-to-events.ts: Stub für DB→Event-Sync-Migration.
  Idempotent via idempotencyKey='migration:<row-id>:<event-type>'.
  Plaintext-Migration (kein User-Master-Key zur Migration-Zeit),
  --dry-run/--commit, --user-id-Filter. mintToken() noch als Stub
  (braucht Service-Key-basierten JWT-Mint in mana-auth)

apps/web:
- lib/api/decks.ts: generateDeck wrapped jetzt den Server-Vorschlag
  via lokales createDeck + createCard-Burst. UI sieht weiterhin
  { deck, cards_created } als Return-Shape

apps/api/tests:
- decks.test.ts: post-cutover-Smokes (Auth-Check + 404 für entfernte
  Routes)
- cards.test.ts: 410-Gone-Verification mit Deprecation-Header
- reviews.test.ts: 410-Gone-Verification

Type-check 0 Errors. Test-Suite: pre-existing fails (dsgvo, share,
tools — alle pre-cutover schon rot, Rebrand-Drift cards→wordeck);
meine drei Big-Bang-Tests-Files 7/7 grün.

Offene Punkte (bewusst geflaggt, nicht Big-Bang-Block):
- DSGVO-Pfad cross-source-Aggregation (sync2 + DB) ist mana-admin's
  Architektur-Job, nicht wordeck-app
- Migration-Script mintToken() braucht mana-auth-Service-Key-Pfad
  oder sync2-Service-Key-Auth-Mode (Plattform-Arbeit)
- Live-User-Migration: Skript-Stub ist ungetestet, muss vor echtem
  Run code-reviewed + 1-2 User-Dry-Runs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-20 21:51:22 +02:00
parent bebd182540
commit 375a6af86e
10 changed files with 555 additions and 1010 deletions

View file

@ -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:<row-id>:<event-type>'`.
*
* Verwendung (immer dry-run probieren bevor echter Run):
*
* pnpm tsx scripts/migrate-db-to-events.ts --dry-run [--user-id <id>]
* pnpm tsx scripts/migrate-db-to-events.ts --commit [--user-id <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<string, unknown>;
}
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<string, unknown>,
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<string> {
// 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<void> {
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<void> {
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<void> {
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();

View file

@ -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', '<https://wordeck.com/datenschutz#7a>; 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;
}

View file

@ -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<string, never>;
// 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<typeof GeneratedDeckSchema>;
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;

View file

@ -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<string, string>,
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<string | null>`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<string, string>;
const v = f[fieldParam];
if (typeof v === 'string' && v.length > 0) {
distractors.push(v);
}
}
return c.json({ distractors });
});

View file

@ -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 [

View file

@ -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', '<https://wordeck.com/datenschutz#7a>; 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<string, unknown>;
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<string, unknown>;
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,
};
}

View file

@ -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');
});
});

View file

@ -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<string, Row>();
const fakeDb = {
insert: (_table: unknown) => ({
values: (vals: Partial<Row> & { 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<Row[]>, {
limit: (_n: number) => items.slice(0, _n),
});
},
}),
}),
update: (_table: unknown) => ({
set: (patch: Partial<Row>) => ({
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);
});
});

View file

@ -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);
});
});

View file

@ -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(