Phase 3: Domain-Modell + Decks/Cards/Reviews-CRUD

Domain (@cards/domain):
- zod-Schemas SSOT für Deck, Card, Review, StudySession, FsrsSettings,
  Tools (cards.create + cards.search Input/Output)
- CardType-Discriminated-Union: MVP basic+basic-reverse, Future-Set
  (cloze, type-in, image-occlusion, audio, multiple-choice) für
  Schema-stable-Migration vorbereitet
- validateFieldsForType() Pure-Function pro CardType
- FSRS-Adapter über ts-fsrs v5.3.2: newReview, gradeReview,
  subIndexCount, toFsrsCard/fromFsrsCard ISO↔Date-Roundtrip
- Encryption-Hinweis: reviews bleiben PLAINTEXT (Scheduler quert
  täglich `due <= now`, siehe Lessons §3)

Drizzle-Schemas (apps/api/src/db/schema, alles in pgSchema('cards')):
- decks, cards, card_tags, reviews (PK card_id+sub_index), study_sessions,
  tags (deck-skopiert), media_refs (verweist auf mana-media), import_jobs
- _schema.ts-Pattern um Zirkular-Imports zu vermeiden (Lesson aus
  mana-share/-events während F-0)
- Hot-Path-Index reviews_user_due_idx für Scheduler-Queries

Routes (apps/api/src/routes):
- POST/GET/PATCH/DELETE /api/v1/decks (Deck-CRUD)
- POST/GET/PATCH/DELETE /api/v1/cards (Card-CRUD mit Auto-Reviews-Init:
  beim Card-Insert werden N Reviews via subIndexCount(type) angelegt,
  in einer Transaktion)
- GET /api/v1/reviews/due (Hot-Path, optional deck_id-Filter, Limit 500)
- POST /api/v1/reviews/:cardId/:subIndex/grade (FSRS-State-Transition,
  per-Deck FSRS-Settings)

Auth: Stub-Middleware liest X-User-Id-Header (Phase 2 ersetzt durch
@mana/shared-hono authMiddleware mit JWKS-Cache).

Tests (vitest, Hono app.request()):
- @cards/domain: fsrs.test.ts (newReview, gradeReview Roundtrip,
  Rating-Mapping), schemas.test.ts (zod-strict-Variants, Field-Type-
  Validation, hex-Color)
- apps/api: decks.test.ts + cards.test.ts + reviews.test.ts —
  Auth-Gate + Input-Validation. Volle DB-Integrationstests folgen mit
  pg-mem oder testcontainers in späterer Phase.

Cleanup: types.ts entfernt, zod-Schemas sind SSOT (z.infer für Types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till 2026-05-08 14:21:54 +02:00
parent 8605b1b517
commit 45a47e0ffd
31 changed files with 1897 additions and 106 deletions

View file

@ -0,0 +1,28 @@
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema/index.ts';
export type CardsDb = PostgresJsDatabase<typeof schema>;
let _db: CardsDb | null = null;
let _client: postgres.Sql<{}> | null = null;
export function getDb(): CardsDb {
if (_db) return _db;
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error('DATABASE_URL not set — Cards-API kann nicht ohne Postgres laufen');
}
_client = postgres(url, { max: 10 });
_db = drizzle(_client, { schema });
return _db;
}
export async function closeDb(): Promise<void> {
if (_client) {
await _client.end();
_client = null;
_db = null;
}
}

View file

@ -0,0 +1,7 @@
// Single Source of Truth für die pgSchema-Deklaration.
// Wird von allen Tabellen-Files importiert, vermeidet Zirkular-Imports
// (siehe Lesson aus mana-share/-events während F-0).
import { pgSchema } from 'drizzle-orm/pg-core';
export const cardsSchema = pgSchema('cards');

View file

@ -0,0 +1,65 @@
import { index, jsonb, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts';
import { decks } from './decks.ts';
/**
* Karten. `fields` ist ein generischer JSONB-Slot, der je nach `type`
* unterschiedliche Felder enthält:
*
* - basic / basic-reverse: { front, back }
* - cloze: { text, extra? }
* - type-in: { question, expected }
* - image-occlusion: { image_ref, mask_regions: [...] }
*
* MVP unterstützt nur `basic` und `basic-reverse`.
*
* `tags` ist ein eigener Tag-Mapping-Layer (siehe `tags.ts` + `cardTags`),
* NICHT in dieser Tabelle gespeichert.
*/
export const cards = cardsSchema.table(
'cards',
{
id: text('id').primaryKey(),
deckId: text('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
type: text('type').notNull(),
fields: jsonb('fields').notNull(),
mediaRefs: jsonb('media_refs').notNull().$type<string[]>().default([]),
contentHash: text('content_hash'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
},
(t) => ({
deckIdx: index('cards_deck_idx').on(t.deckId),
userIdx: index('cards_user_idx').on(t.userId),
})
);
export type CardRow = typeof cards.$inferSelect;
export type CardInsert = typeof cards.$inferInsert;
/**
* CardTag-Mapping. PK ist (card_id, tag_id) kein eigenes id-Feld nötig.
*/
export const cardTags = cardsSchema.table(
'card_tags',
{
cardId: text('card_id')
.notNull()
.references(() => cards.id, { onDelete: 'cascade' }),
tagId: text('tag_id').notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.cardId, t.tagId] }),
})
);
export type CardTagRow = typeof cardTags.$inferSelect;
export type CardTagInsert = typeof cardTags.$inferInsert;

View file

@ -0,0 +1,37 @@
import { sql } from 'drizzle-orm';
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts';
/**
* Decks Sammlungen von Karten. Eine Karte gehört zu genau einem Deck.
* `fsrs_settings` ist ein JSONB-Slot für per-Deck-Overrides der globalen
* FSRS-Defaults (siehe @cards/domain `FsrsSettings`).
*/
export const decks = cardsSchema.table(
'decks',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
name: text('name').notNull(),
description: text('description'),
color: text('color'),
visibility: text('visibility', { enum: ['private', 'space', 'public'] })
.notNull()
.default('private'),
fsrsSettings: jsonb('fsrs_settings').notNull().default(sql`'{}'::jsonb`),
contentHash: text('content_hash'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
},
(t) => ({
userIdx: index('decks_user_idx').on(t.userId),
})
);
export type DeckRow = typeof decks.$inferSelect;
export type DeckInsert = typeof decks.$inferInsert;

View file

@ -0,0 +1,34 @@
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts';
/**
* Import-Jobs für Bulk-Vorgänge (Anki-.apkg, CSV-Upload, etc.).
* Der Job-Status durchläuft `queued` `processing` `done | failed`.
* `meta` ist ein freier JSONB-Slot für Source-spezifische Infos
* (Datei-Name, Mapping-Tabellen, Fortschritt).
*/
export const importJobs = cardsSchema.table(
'import_jobs',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
source: text('source', { enum: ['anki', 'csv', 'json'] }).notNull(),
state: text('state', { enum: ['queued', 'processing', 'done', 'failed'] })
.notNull()
.default('queued'),
meta: jsonb('meta'),
error: text('error'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true, mode: 'date' }),
},
(t) => ({
userIdx: index('imports_user_idx').on(t.userId),
stateIdx: index('imports_state_idx').on(t.state),
})
);
export type ImportJobRow = typeof importJobs.$inferSelect;
export type ImportJobInsert = typeof importJobs.$inferInsert;

View file

@ -1,13 +1,29 @@
// Drizzle-Schemas für die `cards`-Datenbank. // Public Re-exports für Drizzle-Schemas.
// //
// Phase-3-Aufgabe (siehe CARDS_GREENFIELD.md): hier landen // Konvention: Tabellen-Files importieren `cardsSchema` aus `_schema.ts`
// `decks`, `cards`, `reviews`, `study_sessions`, `tags`, `media_refs`, // (nie aus `index.ts`), damit es keine Zirkular-Imports gibt.
// `import_jobs` als pgSchema('cards').table(...) Definitionen.
//
// Schema-Skizze in mana/docs/playbooks/CARDS_GREENFIELD.md §"Drizzle-Schema-Skizze".
// Card-Type-Granularität (subIndex pro Karte) aus
// docs/LESSONS_FROM_MANA_MONOREPO.md mitnehmen.
import { pgSchema } from 'drizzle-orm/pg-core'; export { cardsSchema } from './_schema.ts';
export const cardsSchema = pgSchema('cards'); export { decks } from './decks.ts';
export type { DeckRow, DeckInsert } from './decks.ts';
export { cards, cardTags } from './cards.ts';
export type { CardRow, CardInsert, CardTagRow, CardTagInsert } from './cards.ts';
export { reviews, studySessions } from './reviews.ts';
export type {
ReviewRow,
ReviewInsert,
StudySessionRow,
StudySessionInsert,
} from './reviews.ts';
export { tags } from './tags.ts';
export type { TagRow, TagInsert } from './tags.ts';
export { mediaRefs } from './media.ts';
export type { MediaRefRow, MediaRefInsert } from './media.ts';
export { importJobs } from './imports.ts';
export type { ImportJobRow, ImportJobInsert } from './imports.ts';

View file

@ -0,0 +1,32 @@
import { index, integer, text, timestamp } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts';
import { cards } from './cards.ts';
/**
* Media-Verweise auf Object-IDs in mana-media. Die eigentlichen Files
* (Bilder, Audio, Video) liegen in MinIO via mana-media; diese Tabelle
* hält nur den Verweis + Sortier-Order pro Karte.
*/
export const mediaRefs = cardsSchema.table(
'media_refs',
{
id: text('id').primaryKey(),
cardId: text('card_id')
.notNull()
.references(() => cards.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
manaMediaObjectId: text('mana_media_object_id').notNull(),
kind: text('kind', { enum: ['image', 'audio', 'video'] }).notNull(),
ord: integer('ord').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
},
(t) => ({
cardIdx: index('media_card_idx').on(t.cardId),
})
);
export type MediaRefRow = typeof mediaRefs.$inferSelect;
export type MediaRefInsert = typeof mediaRefs.$inferInsert;

View file

@ -0,0 +1,76 @@
import { index, integer, primaryKey, real, text, timestamp } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts';
import { cards } from './cards.ts';
import { decks } from './decks.ts';
/**
* FSRS-Review-State pro `(card, sub_index)`.
*
* `sub_index` granular:
* - basic: 1 Review (sub_index = 0)
* - basic-reverse: 2 Reviews (0 = frontback, 1 = backfront)
* - cloze: 1 Review pro Cluster-Index ({{c1::}} sub_index = 1)
*
* **Bewusst PLAINTEXT** (siehe `docs/LESSONS_FROM_MANA_MONOREPO.md` §3):
* Der Scheduler quert täglich `due <= now` Encryption müsste das
* jedes Mal entschlüsseln. mana-monorepo hat das gleiche Pattern:
* cardReviews ist plaintext-allowlisted.
*/
export const reviews = cardsSchema.table(
'reviews',
{
cardId: text('card_id')
.notNull()
.references(() => cards.id, { onDelete: 'cascade' }),
subIndex: integer('sub_index').notNull().default(0),
userId: text('user_id').notNull(),
due: timestamp('due', { withTimezone: true, mode: 'date' }).notNull(),
stability: real('stability').notNull(),
difficulty: real('difficulty').notNull(),
elapsedDays: real('elapsed_days').notNull().default(0),
scheduledDays: real('scheduled_days').notNull().default(0),
reps: integer('reps').notNull().default(0),
lapses: integer('lapses').notNull().default(0),
state: text('state', { enum: ['new', 'learning', 'review', 'relearning'] })
.notNull()
.default('new'),
lastReview: timestamp('last_review', { withTimezone: true, mode: 'date' }),
},
(t) => ({
pk: primaryKey({ columns: [t.cardId, t.subIndex] }),
// Hot Path: Scheduler quert täglich `due <= now` für einen User.
userDueIdx: index('reviews_user_due_idx').on(t.userId, t.due),
})
);
export type ReviewRow = typeof reviews.$inferSelect;
export type ReviewInsert = typeof reviews.$inferInsert;
/**
* Study-Sessions als Statistik-Layer. Eine Session läuft pro
* `(user, deck)`-Studieren-Lauf, wird beim Start angelegt und beim
* Ende mit Total-Counts geupdatet.
*/
export const studySessions = cardsSchema.table(
'study_sessions',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
deckId: text('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
startedAt: timestamp('started_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true, mode: 'date' }),
cardsReviewed: integer('cards_reviewed').notNull().default(0),
cardsCorrect: integer('cards_correct').notNull().default(0),
},
(t) => ({
userStartedIdx: index('sessions_user_started_idx').on(t.userId, t.startedAt),
})
);
export type StudySessionRow = typeof studySessions.$inferSelect;
export type StudySessionInsert = typeof studySessions.$inferInsert;

View file

@ -0,0 +1,30 @@
import { index, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts';
import { decks } from './decks.ts';
/**
* Tags sind deck-skopiert (laut mana-monorepo-Pattern). Ein Tag-Name
* kann pro Deck nur einmal vorkommen.
*/
export const tags = cardsSchema.table(
'tags',
{
id: text('id').primaryKey(),
deckId: text('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
},
(t) => ({
deckIdx: index('tags_deck_idx').on(t.deckId),
uniqueByDeckName: uniqueIndex('tags_deck_name_uniq').on(t.deckId, t.name),
})
);
export type TagRow = typeof tags.$inferSelect;
export type TagInsert = typeof tags.$inferInsert;

View file

@ -2,11 +2,17 @@ import { Hono } from 'hono';
import { manifestRoute } from './routes/manifest.ts'; import { manifestRoute } from './routes/manifest.ts';
import { healthRoute } from './routes/health.ts'; import { healthRoute } from './routes/health.ts';
import { decksRouter } from './routes/decks.ts';
import { cardsRouter } from './routes/cards.ts';
import { reviewsRouter } from './routes/reviews.ts';
const app = new Hono(); const app = new Hono();
app.route('/', healthRoute); app.route('/', healthRoute);
app.route('/.well-known/mana-app.json', manifestRoute); app.route('/.well-known/mana-app.json', manifestRoute);
app.route('/api/v1/decks', decksRouter());
app.route('/api/v1/cards', cardsRouter());
app.route('/api/v1/reviews', reviewsRouter());
app.get('/', (c) => app.get('/', (c) =>
c.json({ c.json({

46
apps/api/src/lib/ulid.ts Normal file
View file

@ -0,0 +1,46 @@
/**
* ULID-Generator (Crockford-Base-32).
* Bewusst lokal statt ulid-Lib, damit keine zusätzliche Abhängigkeit.
*
* 26 Zeichen: 10 Time + 16 Random.
* Lexikografisch sortierbar, monoton steigend.
*/
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
function randomBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
return buf;
}
function encodeTime(now: number): string {
let s = '';
let n = now;
for (let i = 0; i < 10; i++) {
const r = n % 32;
s = CROCKFORD[r] + s;
n = Math.floor(n / 32);
}
return s;
}
function encodeRandom(): string {
const bytes = randomBytes(10);
let bits = 0;
let acc = 0;
let s = '';
for (const b of bytes) {
acc = (acc << 8) | b;
bits += 8;
while (bits >= 5) {
bits -= 5;
s += CROCKFORD[(acc >> bits) & 0x1f];
}
}
return s;
}
export function ulid(now: number = Date.now()): string {
return encodeTime(now) + encodeRandom();
}

View file

@ -0,0 +1,27 @@
import type { Context, MiddlewareHandler } from 'hono';
/**
* Auth-Middleware-Stub für Phase 3.
*
* Heute (Dev): liest `X-User-Id`-Header.
* Phase 2 (echt): validiert User-JWT gegen mana-auth JWKS und extrahiert
* `sub`-Claim als userId.
*
* Implementations-Notiz: Phase 2 schwenkt auf `@mana/shared-hono`'s
* `authMiddleware()` um, das den JWKS-Cache verwaltet.
*/
export type AuthVars = { userId: string };
export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async (c, next) => {
const userId = c.req.header('X-User-Id');
if (!userId) {
return c.json({ error: 'unauthenticated', detail: 'X-User-Id header missing (dev stub)' }, 401);
}
c.set('userId', userId);
await next();
};
/** Helper zum Auslesen des userId aus dem Context (typed). */
export function getUserId(c: Context<{ Variables: AuthVars }>): string {
return c.get('userId');
}

View file

@ -0,0 +1,163 @@
import { and, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { CardCreateSchema, CardUpdateSchema, newReview, subIndexCount } from '@cards/domain';
import { getDb, type CardsDb } from '../db/connection.ts';
import { cards, decks, reviews } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { ulid } from '../lib/ulid.ts';
export type CardsDeps = { db?: CardsDb };
export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
/**
* Karte erstellen + automatisch initiale Reviews anlegen.
*
* Pro Card-Type werden N `(card_id, sub_index)`-Reviews angelegt
* (basic = 1, basic-reverse = 2). Alles in einer Transaktion.
*/
r.post('/', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = CardCreateSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const userId = c.get('userId');
const [deck] = await dbOf()
.select({ id: decks.id, userId: decks.userId })
.from(decks)
.where(eq(decks.id, parsed.data.deck_id))
.limit(1);
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
if (deck.userId !== userId) return c.json({ error: 'deck_not_owned' }, 403);
const cardId = ulid();
const now = new Date();
const subIndices = Array.from({ length: subIndexCount(parsed.data.type) }, (_, i) => i);
const [cardRow] = await dbOf().transaction(async (tx) => {
const [card] = await tx
.insert(cards)
.values({
id: cardId,
deckId: parsed.data.deck_id,
userId,
type: parsed.data.type,
fields: parsed.data.fields,
mediaRefs: parsed.data.media_refs ?? [],
createdAt: now,
updatedAt: now,
})
.returning();
const initialReviews = subIndices.map((subIndex) => {
const r = newReview({ userId, cardId, subIndex, now });
return {
cardId: r.card_id,
subIndex: r.sub_index,
userId: r.user_id,
due: new Date(r.due),
stability: r.stability,
difficulty: r.difficulty,
elapsedDays: r.elapsed_days,
scheduledDays: r.scheduled_days,
reps: r.reps,
lapses: r.lapses,
state: r.state,
lastReview: r.last_review ? new Date(r.last_review) : null,
};
});
if (initialReviews.length > 0) {
await tx.insert(reviews).values(initialReviews);
}
return [card];
});
return c.json(toCardDto(cardRow), 201);
});
r.get('/', async (c) => {
const userId = c.get('userId');
const deckId = c.req.query('deck_id');
const conditions = deckId
? and(eq(cards.userId, userId), eq(cards.deckId, deckId))
: eq(cards.userId, userId);
const rows = await dbOf().select().from(cards).where(conditions);
return c.json({ cards: rows.map(toCardDto), total: rows.length });
});
r.get('/:id', async (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const [row] = await dbOf()
.select()
.from(cards)
.where(and(eq(cards.id, id), eq(cards.userId, userId)))
.limit(1);
if (!row) return c.json({ error: 'not_found' }, 404);
return c.json(toCardDto(row));
});
r.patch('/:id', async (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const body = await c.req.json().catch(() => null);
const parsed = CardUpdateSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const [row] = await dbOf()
.update(cards)
.set({
...(parsed.data.fields !== undefined && { fields: parsed.data.fields }),
...(parsed.data.media_refs !== undefined && { mediaRefs: parsed.data.media_refs }),
updatedAt: new Date(),
})
.where(and(eq(cards.id, id), eq(cards.userId, userId)))
.returning();
if (!row) return c.json({ error: 'not_found' }, 404);
return c.json(toCardDto(row));
});
r.delete('/:id', async (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const result = await dbOf()
.delete(cards)
.where(and(eq(cards.id, id), eq(cards.userId, userId)))
.returning({ id: cards.id });
if (result.length === 0) return c.json({ error: 'not_found' }, 404);
// reviews kaskadiert per onDelete: 'cascade' in der Schema-Definition.
return c.json({ deleted: id });
});
return r;
}
function toCardDto(row: typeof cards.$inferSelect) {
return {
id: row.id,
deck_id: row.deckId,
user_id: row.userId,
type: row.type,
fields: row.fields,
media_refs: row.mediaRefs ?? [],
content_hash: row.contentHash,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}

View file

@ -0,0 +1,123 @@
import { and, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain';
import { getDb, type CardsDb } from '../db/connection.ts';
import { decks } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.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 }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
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,
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 rows = await dbOf().select().from(decks).where(eq(decks.userId, userId));
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 [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.visibility !== undefined && { visibility: parsed.data.visibility }),
...(parsed.data.fsrs_settings !== undefined && {
fsrsSettings: parsed.data.fsrs_settings,
}),
updatedAt: new Date(),
})
.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 });
});
return r;
}
function toDeckDto(row: typeof decks.$inferSelect) {
return {
id: row.id,
user_id: row.userId,
name: row.name,
description: row.description,
color: row.color,
visibility: row.visibility,
fsrs_settings: row.fsrsSettings,
content_hash: row.contentHash,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}

View file

@ -0,0 +1,160 @@
import { and, asc, eq, lte } from 'drizzle-orm';
import { Hono } from 'hono';
import {
GradeReviewInputSchema,
gradeReview,
type Review as DomainReview,
} from '@cards/domain';
import { getDb, type CardsDb } from '../db/connection.ts';
import { cards, decks, reviews } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
export type ReviewsDeps = { db?: CardsDb };
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 limit = Math.min(Number(c.req.query('limit') ?? 100), 500);
const now = new Date();
const conditions = [eq(reviews.userId, userId), lte(reviews.due, now)];
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(asc(reviews.due))
.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(asc(reviews.due))
.limit(limit);
return c.json({ reviews: rows.map(toReviewDto), total: rows.length });
});
/**
* 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) ?? {}
);
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,
reps: next.reps,
lapses: next.lapses,
state: next.state,
lastReview: next.last_review ? new Date(next.last_review) : null,
})
.where(
and(
eq(reviews.cardId, cardId),
eq(reviews.subIndex, subIndex),
eq(reviews.userId, userId)
)
)
.returning();
return c.json(toReviewDto(updated));
});
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,
reps: row.reps,
lapses: row.lapses,
state: row.state,
last_review: row.lastReview ? row.lastReview.toISOString() : null,
};
}

View file

@ -0,0 +1,98 @@
import { describe, it, expect } 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() {
const app = new Hono();
const stub = {
select: () => ({
from: () => ({
where: () => ({ limit: () => [] }),
}),
}),
};
app.route('/api/v1/cards', cardsRouter({ db: stub as unknown as CardsDb }));
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 — 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: 'cloze',
fields: { text: 'x' },
}),
});
expect(res.status).toBe(422);
});
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

@ -0,0 +1,164 @@
import { describe, it, expect } 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.
const app = new Hono();
app.route('/api/v1/decks', decksRouter({ db: fakeDb as unknown as CardsDb }));
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');
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.
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);
});
});
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);
});
});

View file

@ -0,0 +1,89 @@
import { describe, it, expect } 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: () => [] }),
}),
}),
}),
};
const app = new Hono();
app.route('/api/v1/reviews', reviewsRouter({ db: stub as unknown as CardsDb }));
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);
});
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', {
method: 'POST',
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');
});
});

View file

@ -8,9 +8,7 @@
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./types": "./src/types.ts",
"./fsrs": "./src/fsrs.ts", "./fsrs": "./src/fsrs.ts",
"./cloze": "./src/cloze.ts",
"./schemas": "./src/schemas/index.ts" "./schemas": "./src/schemas/index.ts"
}, },
"scripts": { "scripts": {

View file

@ -0,0 +1,153 @@
// FSRS-Adapter über ts-fsrs v5.3.2.
//
// Pattern aus mana-monorepo (siehe docs/LESSONS_FROM_MANA_MONOREPO.md §3):
// - Unsere Reviews speichern ISO-Strings + camelCase-Felder.
// - ts-fsrs arbeitet mit Date-Objekten + snake_case.
// - Diese Datei ist die Übersetzungs-Schicht.
// - Reviews bleiben PLAINTEXT (Scheduler quert täglich `due <= now`).
import {
createEmptyCard,
default_w,
FSRS,
generatorParameters,
Rating as FsrsRating,
State as FsrsState,
type Card as FsrsCard,
type FSRSParameters,
} from 'ts-fsrs';
import type { Rating, Review, ReviewState } from './schemas/review.ts';
import type { FsrsSettings } from './schemas/fsrs-settings.ts';
/** Public Rating ↔ ts-fsrs Rating mapping. */
const RATING_TO_FSRS: Record<Rating, FsrsRating> = {
again: FsrsRating.Again,
hard: FsrsRating.Hard,
good: FsrsRating.Good,
easy: FsrsRating.Easy,
};
/** ts-fsrs State (numeric) ↔ unser ReviewState (string). */
const STATE_FROM_FSRS: Record<FsrsState, ReviewState> = {
[FsrsState.New]: 'new',
[FsrsState.Learning]: 'learning',
[FsrsState.Review]: 'review',
[FsrsState.Relearning]: 'relearning',
};
const STATE_TO_FSRS: Record<ReviewState, FsrsState> = {
new: FsrsState.New,
learning: FsrsState.Learning,
review: FsrsState.Review,
relearning: FsrsState.Relearning,
};
/** Baut einen FSRS-Scheduler aus per-Deck-Settings + globalen Defaults. */
export function buildScheduler(settings: FsrsSettings = {}): FSRS {
const params: FSRSParameters = generatorParameters({
request_retention: settings.request_retention,
maximum_interval: settings.maximum_interval,
w: settings.w ?? default_w,
enable_fuzz: settings.enable_fuzz ?? true,
});
return new FSRS(params);
}
/** Initialer Review-State für eine neue Karte (sub_index). */
export function newReview(args: {
userId: string;
cardId: string;
subIndex?: number;
now?: Date;
}): Review {
const now = args.now ?? new Date();
const fc = createEmptyCard(now);
return {
card_id: args.cardId,
sub_index: args.subIndex ?? 0,
user_id: args.userId,
due: fc.due.toISOString(),
stability: fc.stability,
difficulty: fc.difficulty,
elapsed_days: fc.elapsed_days,
scheduled_days: fc.scheduled_days,
reps: fc.reps,
lapses: fc.lapses,
state: STATE_FROM_FSRS[fc.state],
last_review: fc.last_review ? fc.last_review.toISOString() : null,
};
}
/** Wendet ein User-Rating an und gibt den nächsten Review-State zurück. */
export function gradeReview(
current: Review,
rating: Rating,
now?: Date,
settings?: FsrsSettings
): Review {
const reviewedAt = now ?? new Date();
const scheduler = buildScheduler(settings);
const fc = toFsrsCard(current);
const log = scheduler.repeat(fc, reviewedAt);
const next = log[RATING_TO_FSRS[rating]].card;
return fromFsrsCard(current, next);
}
/** Konvertiert unseren Review-Datensatz in eine ts-fsrs Card. */
export function toFsrsCard(r: Review): FsrsCard {
return {
due: new Date(r.due),
stability: r.stability,
difficulty: r.difficulty,
elapsed_days: r.elapsed_days,
scheduled_days: r.scheduled_days,
reps: r.reps,
lapses: r.lapses,
state: STATE_TO_FSRS[r.state],
last_review: r.last_review ? new Date(r.last_review) : undefined,
};
}
/** Übernimmt FSRS-Result-Card-Felder in unser Review-Objekt. */
export function fromFsrsCard(prev: Review, fc: FsrsCard): Review {
return {
...prev,
due: fc.due.toISOString(),
stability: fc.stability,
difficulty: fc.difficulty,
elapsed_days: fc.elapsed_days,
scheduled_days: fc.scheduled_days,
reps: fc.reps,
lapses: fc.lapses,
state: STATE_FROM_FSRS[fc.state],
last_review: fc.last_review ? fc.last_review.toISOString() : null,
};
}
/**
* Wie viele Reviews pro Card-Type? Wird beim Card-Insert genutzt,
* um die `(card_id, sub_index)`-Reihen zu initialisieren.
*
* Cloze ist Sonderfall: `subIndex` hängt von der Anzahl der Cluster
* im `fields.text` ab; `subIndexCountForCloze(text)` muss separat
* gerufen werden, sobald wir Cloze unterstützen.
*/
export function subIndexCount(type: string): number {
switch (type) {
case 'basic':
return 1;
case 'basic-reverse':
return 2;
case 'type-in':
return 1;
case 'image-occlusion':
return 1; // pro Mask-Region in Phase 8+ angepasst
case 'audio':
return 1;
case 'multiple-choice':
return 1;
default:
return 1;
}
}

View file

@ -2,7 +2,11 @@
// //
// Pure-TS, keine DB/Framework-Abhängigkeiten. Wird vom apps/api // Pure-TS, keine DB/Framework-Abhängigkeiten. Wird vom apps/api
// (Drizzle-Schemas) und apps/web (UI) gleichermaßen konsumiert. // (Drizzle-Schemas) und apps/web (UI) gleichermaßen konsumiert.
//
// Single Source of Truth für Domain-Typen sind die zod-Schemas in
// `./schemas/`. Alle Types werden via `z.infer<typeof XSchema>`
// aus den Schemas abgeleitet — kein duplizierter Type-Layer.
export * from './types.ts'; export * from './schemas/index.ts';
// export * from './fsrs.ts'; // Phase 3: FSRS-Adapter (ts-fsrs) export * from './fsrs.ts';
// export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser // export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser

View file

@ -0,0 +1,94 @@
import { z } from 'zod';
/**
* MVP-CardType-Set. Erweiterung in CARDS_GREENFIELD.md Phase 8+ vorgesehen
* (cloze, type-in, image-occlusion, audio, multiple-choice).
*/
export const CardTypeSchema = z.enum(['basic', 'basic-reverse']);
export type CardType = z.infer<typeof CardTypeSchema>;
/** Future-Set für Schema-Migration-Vorbereitung. */
export const CardTypeFutureSchema = z.enum([
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
/**
* Generischer Field-Slot. Konkrete Field-Sets pro Type werden runtime
* via `validateFieldsForType()` geprüft (siehe unten).
*/
export const CardFieldsSchema = z.record(z.string(), z.string());
export type CardFields = z.infer<typeof CardFieldsSchema>;
/**
* Field-Set-Validierung pro CardType. Keine z.discriminatedUnion, weil
* der Type-Slot generisch bleibt neue CardTypes können ohne Schema-
* Bruch hinzugefügt werden.
*/
export function validateFieldsForType(
type: CardType | z.infer<typeof CardTypeFutureSchema>,
fields: CardFields
): { ok: true } | { ok: false; missing: string[] } {
const required: Record<string, string[]> = {
basic: ['front', 'back'],
'basic-reverse': ['front', 'back'],
cloze: ['text'],
'type-in': ['question', 'expected'],
'image-occlusion': ['image_ref', 'mask_regions'],
audio: ['audio_ref'],
'multiple-choice': ['question', 'options', 'correct_index'],
};
const need = required[type] ?? [];
const missing = need.filter((k) => !(k in fields));
return missing.length === 0 ? { ok: true } : { ok: false, missing };
}
export const CardSchema = z
.object({
id: z.string().min(1),
deck_id: z.string().min(1),
user_id: z.string().min(1),
type: CardTypeSchema,
fields: CardFieldsSchema,
media_refs: z.array(z.string()).default([]),
content_hash: z.string().optional().nullable(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
})
.strict();
export type Card = z.infer<typeof CardSchema>;
export const CardCreateSchema = z
.object({
deck_id: z.string().min(1),
type: CardTypeSchema,
fields: CardFieldsSchema,
tags: z.array(z.string()).optional(),
media_refs: z.array(z.string()).optional(),
})
.strict()
.superRefine((val, ctx) => {
const check = validateFieldsForType(val.type, val.fields);
if (!check.ok) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['fields'],
message: `missing fields for type=${val.type}: ${check.missing.join(', ')}`,
});
}
});
export type CardCreate = z.infer<typeof CardCreateSchema>;
export const CardUpdateSchema = z
.object({
fields: CardFieldsSchema.optional(),
tags: z.array(z.string()).optional(),
media_refs: z.array(z.string()).optional(),
})
.strict();
export type CardUpdate = z.infer<typeof CardUpdateSchema>;

View file

@ -0,0 +1,42 @@
import { z } from 'zod';
import { FsrsSettingsSchema } from './fsrs-settings.ts';
const VisibilitySchema = z.enum(['private', 'space', 'public']);
export const DeckSchema = z
.object({
id: z.string().min(1),
user_id: z.string().min(1),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional().nullable(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional()
.nullable(),
visibility: VisibilitySchema.default('private'),
fsrs_settings: FsrsSettingsSchema.default({}),
content_hash: z.string().optional().nullable(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
})
.strict();
export type Deck = z.infer<typeof DeckSchema>;
export const DeckCreateSchema = z
.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
visibility: VisibilitySchema.optional(),
fsrs_settings: FsrsSettingsSchema.optional(),
})
.strict();
export type DeckCreate = z.infer<typeof DeckCreateSchema>;
export const DeckUpdateSchema = DeckCreateSchema.partial();
export type DeckUpdate = z.infer<typeof DeckUpdateSchema>;

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
/**
* FSRS-Settings (siehe ts-fsrs `FSRSParameters`).
* Alle Felder optional; Defaults werden im Adapter gesetzt.
*/
export const FsrsSettingsSchema = z
.object({
request_retention: z.number().min(0.5).max(0.99).optional(),
maximum_interval: z.number().min(1).max(36500).optional(),
w: z.array(z.number()).length(19).optional(),
enable_fuzz: z.boolean().optional(),
})
.strict();
export type FsrsSettings = z.infer<typeof FsrsSettingsSchema>;

View file

@ -1,7 +1,17 @@
// Phase-3-Aufgabe: zod-Schemas für API-Inputs/Outputs. // Re-exports für zod-Schemas und JSON-Schema-Generierung.
// //
// Konvention: ein zod-Schema pro Domain-Type (DeckSchema, CardSchema, // Verwendung:
// ReviewSchema). API-Routen rufen `Schema.parse()` für Input-Validation, // - apps/api: Input-Validation via `Schema.parse(body)`
// und `zod-to-json-schema` generiert die JSON-Schemas für mana-mcp/-share. // - apps/api: zod-to-json-schema für mana-mcp Tool-Registration
// - apps/web: Type-Inference (`type X = z.infer<typeof XSchema>`)
//
// Konvention: ein Schema-File pro Domain-Bereich. Public-API-Re-Exports
// hier; das Mapping `validateFieldsForType` ist eine Pure-Function aus
// `card.ts`.
export {}; export * from './fsrs-settings.ts';
export * from './deck.ts';
export * from './card.ts';
export * from './review.ts';
export * from './study.ts';
export * from './tools.ts';

View file

@ -0,0 +1,36 @@
import { z } from 'zod';
export const ReviewStateSchema = z.enum(['new', 'learning', 'review', 'relearning']);
export type ReviewState = z.infer<typeof ReviewStateSchema>;
/** ts-fsrs Rating-Map: 1=Again, 2=Hard, 3=Good, 4=Easy. */
export const RatingSchema = z.enum(['again', 'hard', 'good', 'easy']);
export type Rating = z.infer<typeof RatingSchema>;
export const ReviewSchema = z
.object({
card_id: z.string().min(1),
sub_index: z.number().int().nonnegative(),
user_id: z.string().min(1),
due: z.string().datetime(),
stability: z.number().nonnegative(),
difficulty: z.number().min(0).max(10),
elapsed_days: z.number().nonnegative().default(0),
scheduled_days: z.number().nonnegative().default(0),
reps: z.number().int().nonnegative().default(0),
lapses: z.number().int().nonnegative().default(0),
state: ReviewStateSchema.default('new'),
last_review: z.string().datetime().nullable().optional(),
})
.strict();
export type Review = z.infer<typeof ReviewSchema>;
export const GradeReviewInputSchema = z
.object({
card_id: z.string().min(1),
sub_index: z.number().int().nonnegative().default(0),
rating: RatingSchema,
reviewed_at: z.string().datetime().optional(),
})
.strict();
export type GradeReviewInput = z.infer<typeof GradeReviewInputSchema>;

View file

@ -0,0 +1,30 @@
import { z } from 'zod';
export const StudySessionSchema = z
.object({
id: z.string().min(1),
user_id: z.string().min(1),
deck_id: z.string().min(1),
started_at: z.string().datetime(),
finished_at: z.string().datetime().nullable(),
cards_reviewed: z.number().int().nonnegative().default(0),
cards_correct: z.number().int().nonnegative().default(0),
})
.strict();
export type StudySession = z.infer<typeof StudySessionSchema>;
export const StartStudySessionInputSchema = z
.object({
deck_id: z.string().min(1),
})
.strict();
export type StartStudySessionInput = z.infer<typeof StartStudySessionInputSchema>;
export const FinishStudySessionInputSchema = z
.object({
session_id: z.string().min(1),
cards_reviewed: z.number().int().nonnegative(),
cards_correct: z.number().int().nonnegative(),
})
.strict();
export type FinishStudySessionInput = z.infer<typeof FinishStudySessionInputSchema>;

View file

@ -0,0 +1,45 @@
import { z } from 'zod';
import { CardSchema, CardCreateSchema } from './card.ts';
/**
* Schemas für die im app-manifest.json deklarierten AI-Tools.
* Werden via zod-to-json-schema in JSON-Schema konvertiert und an
* mana-mcp ausgeliefert (Phase 7).
*/
export const CardsCreateInputSchema = CardCreateSchema;
export type CardsCreateInput = z.infer<typeof CardsCreateInputSchema>;
export const CardsCreateOutputSchema = CardSchema;
export type CardsCreateOutput = z.infer<typeof CardsCreateOutputSchema>;
export const CardsSearchInputSchema = z
.object({
query: z.string().min(1),
max_results: z.number().int().min(1).max(100).optional(),
})
.strict();
export type CardsSearchInput = z.infer<typeof CardsSearchInputSchema>;
export const CardsSearchHitSchema = z
.object({
id: z.string(),
type: z.literal('card'),
title: z.string(),
snippet: z.string().optional(),
link: z.string().url(),
score: z.number().min(0).max(1),
})
.strict();
export type CardsSearchHit = z.infer<typeof CardsSearchHitSchema>;
export const CardsSearchOutputSchema = z
.object({
query: z.string(),
results: z.array(CardsSearchHitSchema),
total: z.number().int().nonnegative(),
took_ms: z.number().nonnegative(),
})
.strict();
export type CardsSearchOutput = z.infer<typeof CardsSearchOutputSchema>;

View file

@ -1,87 +0,0 @@
// Domain-Typen für Cards.
//
// Modellierung folgt den Lessons aus mana-monorepo
// (siehe docs/LESSONS_FROM_MANA_MONOREPO.md):
//
// - CardType ist eine discriminated union.
// - Card hat `fields: Record<string, string>` als generischen Slot.
// - Pro Karte gibt es N Reviews mit `subIndex` (basic = 1, basic-reverse = 2,
// cloze = 1 pro Cluster).
// - cardReviews bleiben PLAINTEXT, weil der Scheduler täglich auf `due`
// filtert.
/** Phase-1-MVP-Set; Cloze + Image-Occlusion in Phase 8+. */
export type CardType = 'basic' | 'basic-reverse';
/** Voll geplantes Set (für Schemas vorbereitet, MVP nicht alle implementiert). */
export type CardTypeFuture =
| 'basic'
| 'basic-reverse'
| 'cloze'
| 'type-in'
| 'image-occlusion'
| 'audio'
| 'multiple-choice';
export type CardFields = Record<string, string>;
export type Deck = {
id: string;
user_id: string;
name: string;
description?: string;
color?: string;
visibility: 'private' | 'space' | 'public';
fsrs_settings: FsrsSettings;
created_at: string;
updated_at: string;
};
export type Card = {
id: string;
deck_id: string;
user_id: string;
type: CardType;
fields: CardFields;
tags: string[];
media_refs: string[];
created_at: string;
updated_at: string;
};
/**
* Pro `(card_id, sub_index)` ein Review-Eintrag. Der Scheduler quert auf
* `due <= now` täglich das Feld bleibt deshalb plaintext und ist indiziert.
*/
export type Review = {
card_id: string;
sub_index: number;
user_id: string;
due: string;
stability: number;
difficulty: number;
elapsed_days: number;
scheduled_days: number;
reps: number;
lapses: number;
state: 'new' | 'learning' | 'review' | 'relearning';
last_review: string | null;
};
export type StudySession = {
id: string;
user_id: string;
deck_id: string;
started_at: string;
finished_at: string | null;
cards_reviewed: number;
cards_correct: number;
};
/** Default-Konstanten aus ts-fsrs; per-Deck-Overrides möglich. */
export type FsrsSettings = {
requestRetention?: number;
maximumInterval?: number;
w?: number[];
enableFuzz?: boolean;
};

View file

@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import {
buildScheduler,
fromFsrsCard,
gradeReview,
newReview,
subIndexCount,
toFsrsCard,
} from '../src/fsrs.ts';
describe('newReview', () => {
it('initializes with state=new and reps=0', () => {
const r = newReview({
userId: 'u-1',
cardId: 'c-1',
now: new Date('2026-05-08T10:00:00Z'),
});
expect(r.card_id).toBe('c-1');
expect(r.sub_index).toBe(0);
expect(r.user_id).toBe('u-1');
expect(r.state).toBe('new');
expect(r.reps).toBe(0);
expect(r.lapses).toBe(0);
expect(r.due).toBe('2026-05-08T10:00:00.000Z');
});
it('honours provided sub_index', () => {
const r = newReview({ userId: 'u-1', cardId: 'c-1', subIndex: 2 });
expect(r.sub_index).toBe(2);
});
});
describe('gradeReview', () => {
const fixedNow = new Date('2026-05-08T10:00:00Z');
const reviewedAt = new Date('2026-05-08T10:01:00Z');
const baseReview = newReview({
userId: 'u-1',
cardId: 'c-1',
now: fixedNow,
});
it('Again from new keeps reps=0 increments lapses', () => {
// Disable fuzz for deterministic test outputs
const next = gradeReview(baseReview, 'again', reviewedAt, { enable_fuzz: false });
expect(next.state).not.toBe('new');
expect(next.due).not.toBe(baseReview.due);
expect(next.reps).toBeGreaterThanOrEqual(1);
});
it('Easy from new transitions to a future-dated review', () => {
const next = gradeReview(baseReview, 'easy', reviewedAt, { enable_fuzz: false });
expect(new Date(next.due).getTime()).toBeGreaterThan(reviewedAt.getTime());
});
it('preserves card_id, sub_index, user_id', () => {
const next = gradeReview(baseReview, 'good', reviewedAt, { enable_fuzz: false });
expect(next.card_id).toBe(baseReview.card_id);
expect(next.sub_index).toBe(baseReview.sub_index);
expect(next.user_id).toBe(baseReview.user_id);
});
});
describe('toFsrsCard / fromFsrsCard roundtrip', () => {
it('roundtrips a new review without loss', () => {
const r = newReview({ userId: 'u-1', cardId: 'c-1' });
const fc = toFsrsCard(r);
const back = fromFsrsCard(r, fc);
expect(back.due).toBe(r.due);
expect(back.stability).toBe(r.stability);
expect(back.state).toBe(r.state);
});
});
describe('subIndexCount', () => {
it('basic = 1, basic-reverse = 2', () => {
expect(subIndexCount('basic')).toBe(1);
expect(subIndexCount('basic-reverse')).toBe(2);
});
it('unknown type defaults to 1', () => {
expect(subIndexCount('unknown-future-type')).toBe(1);
});
});
describe('buildScheduler', () => {
it('builds with defaults', () => {
const s = buildScheduler();
expect(s).toBeDefined();
});
it('honours per-deck overrides', () => {
const s = buildScheduler({ request_retention: 0.85, enable_fuzz: false });
expect(s).toBeDefined();
});
});

View file

@ -0,0 +1,154 @@
import { describe, it, expect } from 'vitest';
import {
CardCreateSchema,
CardSchema,
CardTypeSchema,
DeckCreateSchema,
DeckSchema,
GradeReviewInputSchema,
validateFieldsForType,
} from '../src/schemas/index.ts';
describe('CardTypeSchema', () => {
it('accepts MVP types', () => {
expect(() => CardTypeSchema.parse('basic')).not.toThrow();
expect(() => CardTypeSchema.parse('basic-reverse')).not.toThrow();
});
it('rejects future types in MVP schema', () => {
expect(() => CardTypeSchema.parse('cloze')).toThrow();
});
});
describe('validateFieldsForType', () => {
it('basic requires front + back', () => {
expect(validateFieldsForType('basic', { front: 'q', back: 'a' })).toEqual({ ok: true });
expect(validateFieldsForType('basic', { front: 'q' })).toEqual({
ok: false,
missing: ['back'],
});
});
it('cloze requires text', () => {
expect(validateFieldsForType('cloze', { text: 'x' })).toEqual({ ok: true });
expect(validateFieldsForType('cloze', {})).toEqual({ ok: false, missing: ['text'] });
});
});
describe('CardCreateSchema', () => {
it('accepts a basic card', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
});
expect(r.success).toBe(true);
});
it('rejects basic card without back', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q' },
});
expect(r.success).toBe(false);
});
it('rejects unknown type via CardTypeSchema', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'cloze',
fields: { text: 'x' },
});
expect(r.success).toBe(false);
});
it('rejects extra fields (strict)', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
malicious: 'inject',
});
expect(r.success).toBe(false);
});
});
describe('DeckCreateSchema', () => {
it('minimal valid deck', () => {
const r = DeckCreateSchema.safeParse({ name: 'My Deck' });
expect(r.success).toBe(true);
});
it('rejects empty name', () => {
const r = DeckCreateSchema.safeParse({ name: '' });
expect(r.success).toBe(false);
});
it('rejects invalid color', () => {
const r = DeckCreateSchema.safeParse({ name: 'X', color: 'red' });
expect(r.success).toBe(false);
});
it('accepts hex color', () => {
const r = DeckCreateSchema.safeParse({ name: 'X', color: '#ff8800' });
expect(r.success).toBe(true);
});
});
describe('GradeReviewInputSchema', () => {
it('accepts a grade input', () => {
const r = GradeReviewInputSchema.safeParse({
card_id: 'c-1',
sub_index: 0,
rating: 'good',
});
expect(r.success).toBe(true);
});
it('rejects unknown rating', () => {
const r = GradeReviewInputSchema.safeParse({
card_id: 'c-1',
sub_index: 0,
rating: 'perfect',
});
expect(r.success).toBe(false);
});
it('defaults sub_index to 0', () => {
const r = GradeReviewInputSchema.parse({ card_id: 'c-1', rating: 'good' });
expect(r.sub_index).toBe(0);
});
});
describe('strict variants reject extras', () => {
it('DeckSchema rejects extra props', () => {
const r = DeckSchema.safeParse({
id: 'd-1',
user_id: 'u-1',
name: 'D',
visibility: 'private',
fsrs_settings: {},
created_at: '2026-05-08T10:00:00.000Z',
updated_at: '2026-05-08T10:00:00.000Z',
leaks: 'no',
});
expect(r.success).toBe(false);
});
it('CardSchema rejects extra props', () => {
const r = CardSchema.safeParse({
id: 'c-1',
deck_id: 'd-1',
user_id: 'u-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
media_refs: [],
created_at: '2026-05-08T10:00:00.000Z',
updated_at: '2026-05-08T10:00:00.000Z',
leaked: 'yes',
});
expect(r.success).toBe(false);
});
});