feat(wordeck): Big-Bang-Cutover L-2 — Server-CRUD raus, alles auf event-sync
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
bebd182540
commit
375a6af86e
10 changed files with 555 additions and 1010 deletions
310
apps/api/scripts/migrate-db-to-events.ts
Normal file
310
apps/api/scripts/migrate-db-to-events.ts
Normal 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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue