diff --git a/apps/api/src/db/connection.ts b/apps/api/src/db/connection.ts new file mode 100644 index 0000000..86d0b3f --- /dev/null +++ b/apps/api/src/db/connection.ts @@ -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; + +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 { + if (_client) { + await _client.end(); + _client = null; + _db = null; + } +} diff --git a/apps/api/src/db/schema/_schema.ts b/apps/api/src/db/schema/_schema.ts new file mode 100644 index 0000000..711b782 --- /dev/null +++ b/apps/api/src/db/schema/_schema.ts @@ -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'); diff --git a/apps/api/src/db/schema/cards.ts b/apps/api/src/db/schema/cards.ts new file mode 100644 index 0000000..84be8cb --- /dev/null +++ b/apps/api/src/db/schema/cards.ts @@ -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().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; + +/** + * Card↔Tag-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; diff --git a/apps/api/src/db/schema/decks.ts b/apps/api/src/db/schema/decks.ts new file mode 100644 index 0000000..06e65e4 --- /dev/null +++ b/apps/api/src/db/schema/decks.ts @@ -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; diff --git a/apps/api/src/db/schema/imports.ts b/apps/api/src/db/schema/imports.ts new file mode 100644 index 0000000..3f55cd0 --- /dev/null +++ b/apps/api/src/db/schema/imports.ts @@ -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; diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index e84f525..a3dd7be 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -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 -// `decks`, `cards`, `reviews`, `study_sessions`, `tags`, `media_refs`, -// `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. +// Konvention: Tabellen-Files importieren `cardsSchema` aus `_schema.ts` +// (nie aus `index.ts`), damit es keine Zirkular-Imports gibt. -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'; diff --git a/apps/api/src/db/schema/media.ts b/apps/api/src/db/schema/media.ts new file mode 100644 index 0000000..b23c4db --- /dev/null +++ b/apps/api/src/db/schema/media.ts @@ -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; diff --git a/apps/api/src/db/schema/reviews.ts b/apps/api/src/db/schema/reviews.ts new file mode 100644 index 0000000..bdb5616 --- /dev/null +++ b/apps/api/src/db/schema/reviews.ts @@ -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 = front→back, 1 = back→front) + * - 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; diff --git a/apps/api/src/db/schema/tags.ts b/apps/api/src/db/schema/tags.ts new file mode 100644 index 0000000..d3affa2 --- /dev/null +++ b/apps/api/src/db/schema/tags.ts @@ -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; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 88bd064..85729ef 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,11 +2,17 @@ import { Hono } from 'hono'; import { manifestRoute } from './routes/manifest.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(); app.route('/', healthRoute); 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) => c.json({ diff --git a/apps/api/src/lib/ulid.ts b/apps/api/src/lib/ulid.ts new file mode 100644 index 0000000..6cb577b --- /dev/null +++ b/apps/api/src/lib/ulid.ts @@ -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(); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 0000000..94467c9 --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -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'); +} diff --git a/apps/api/src/routes/cards.ts b/apps/api/src/routes/cards.ts new file mode 100644 index 0000000..e38d9ba --- /dev/null +++ b/apps/api/src/routes/cards.ts @@ -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(), + }; +} diff --git a/apps/api/src/routes/decks.ts b/apps/api/src/routes/decks.ts new file mode 100644 index 0000000..ad83023 --- /dev/null +++ b/apps/api/src/routes/decks.ts @@ -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(), + }; +} diff --git a/apps/api/src/routes/reviews.ts b/apps/api/src/routes/reviews.ts new file mode 100644 index 0000000..2c8fa1c --- /dev/null +++ b/apps/api/src/routes/reviews.ts @@ -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, + }; +} diff --git a/apps/api/tests/cards.test.ts b/apps/api/tests/cards.test.ts new file mode 100644 index 0000000..d8bce5f --- /dev/null +++ b/apps/api/tests/cards.test.ts @@ -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'); + }); +}); diff --git a/apps/api/tests/decks.test.ts b/apps/api/tests/decks.test.ts new file mode 100644 index 0000000..65b82f8 --- /dev/null +++ b/apps/api/tests/decks.test.ts @@ -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(); + + const fakeDb = { + insert: (_table: unknown) => ({ + values: (vals: Partial & { id: string; userId: string }) => ({ + returning: async () => { + const row: Row = { + id: vals.id, + userId: vals.userId, + name: vals.name ?? '', + description: vals.description ?? null, + color: vals.color ?? null, + visibility: vals.visibility ?? 'private', + fsrsSettings: vals.fsrsSettings ?? {}, + contentHash: vals.contentHash ?? null, + createdAt: vals.createdAt ?? new Date(), + updatedAt: vals.updatedAt ?? new Date(), + }; + store.set(row.id, row); + return [row]; + }, + }), + }), + select: () => ({ + from: (_table: unknown) => ({ + where: (filter: { userId?: string; id?: string }) => { + const items = Array.from(store.values()).filter((r) => { + if (filter.userId && r.userId !== filter.userId) return false; + if (filter.id && r.id !== filter.id) return false; + return true; + }); + return Object.assign(items as Row[] | Promise, { + limit: (_n: number) => items.slice(0, _n), + }); + }, + }), + }), + update: (_table: unknown) => ({ + set: (patch: Partial) => ({ + where: (filter: { userId: string; id: string }) => ({ + returning: async () => { + const existing = store.get(filter.id); + if (!existing || existing.userId !== filter.userId) return []; + const updated = { ...existing, ...patch, updatedAt: new Date() }; + store.set(updated.id, updated); + return [updated]; + }, + }), + }), + }), + delete: (_table: unknown) => ({ + where: (filter: { userId: string; id: string }) => ({ + returning: async () => { + const existing = store.get(filter.id); + if (!existing || existing.userId !== filter.userId) return []; + store.delete(filter.id); + return [{ id: filter.id }]; + }, + }), + }), + }; + + // Drizzle's eq/and dont actually pass a function-based filter; the fake-db + // shim above doesn't match real Drizzle wire-shape. So we override the + // interpretation: the test patches eq/and via a simpler comparator. + // For now this fake-DB is sufficient ONLY if the routes' .where()-args + // arrive as plain { userId, id } objects. They don't — they arrive as + // Drizzle-SQL builders. So tests below are scoped to validation/auth paths, + // not full CRUD. + + return { fakeDb, store }; +} + +function buildApp() { + const { fakeDb } = makeFakeDb(); + // Cast — the fakeDb is intentionally minimal and not a full CardsDb. + 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); + }); +}); diff --git a/apps/api/tests/reviews.test.ts b/apps/api/tests/reviews.test.ts new file mode 100644 index 0000000..60956c3 --- /dev/null +++ b/apps/api/tests/reviews.test.ts @@ -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'); + }); +}); diff --git a/packages/cards-domain/package.json b/packages/cards-domain/package.json index c2d8412..7af0d6c 100644 --- a/packages/cards-domain/package.json +++ b/packages/cards-domain/package.json @@ -8,9 +8,7 @@ "types": "./src/index.ts", "exports": { ".": "./src/index.ts", - "./types": "./src/types.ts", "./fsrs": "./src/fsrs.ts", - "./cloze": "./src/cloze.ts", "./schemas": "./src/schemas/index.ts" }, "scripts": { diff --git a/packages/cards-domain/src/fsrs.ts b/packages/cards-domain/src/fsrs.ts new file mode 100644 index 0000000..b3186a7 --- /dev/null +++ b/packages/cards-domain/src/fsrs.ts @@ -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 = { + 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.New]: 'new', + [FsrsState.Learning]: 'learning', + [FsrsState.Review]: 'review', + [FsrsState.Relearning]: 'relearning', +}; + +const STATE_TO_FSRS: Record = { + 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; + } +} diff --git a/packages/cards-domain/src/index.ts b/packages/cards-domain/src/index.ts index e2a9367..92f8426 100644 --- a/packages/cards-domain/src/index.ts +++ b/packages/cards-domain/src/index.ts @@ -2,7 +2,11 @@ // // Pure-TS, keine DB/Framework-Abhängigkeiten. Wird vom apps/api // (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` +// aus den Schemas abgeleitet — kein duplizierter Type-Layer. -export * from './types.ts'; -// export * from './fsrs.ts'; // Phase 3: FSRS-Adapter (ts-fsrs) +export * from './schemas/index.ts'; +export * from './fsrs.ts'; // export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser diff --git a/packages/cards-domain/src/schemas/card.ts b/packages/cards-domain/src/schemas/card.ts new file mode 100644 index 0000000..13dc44d --- /dev/null +++ b/packages/cards-domain/src/schemas/card.ts @@ -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; + +/** 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; + +/** + * 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, + fields: CardFields +): { ok: true } | { ok: false; missing: string[] } { + const required: Record = { + 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; + +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; + +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; diff --git a/packages/cards-domain/src/schemas/deck.ts b/packages/cards-domain/src/schemas/deck.ts new file mode 100644 index 0000000..edf8e79 --- /dev/null +++ b/packages/cards-domain/src/schemas/deck.ts @@ -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; + +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; + +export const DeckUpdateSchema = DeckCreateSchema.partial(); +export type DeckUpdate = z.infer; diff --git a/packages/cards-domain/src/schemas/fsrs-settings.ts b/packages/cards-domain/src/schemas/fsrs-settings.ts new file mode 100644 index 0000000..c19f416 --- /dev/null +++ b/packages/cards-domain/src/schemas/fsrs-settings.ts @@ -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; diff --git a/packages/cards-domain/src/schemas/index.ts b/packages/cards-domain/src/schemas/index.ts index af4d78b..4cb954a 100644 --- a/packages/cards-domain/src/schemas/index.ts +++ b/packages/cards-domain/src/schemas/index.ts @@ -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, -// ReviewSchema). API-Routen rufen `Schema.parse()` für Input-Validation, -// und `zod-to-json-schema` generiert die JSON-Schemas für mana-mcp/-share. +// Verwendung: +// - apps/api: Input-Validation via `Schema.parse(body)` +// - apps/api: zod-to-json-schema für mana-mcp Tool-Registration +// - apps/web: Type-Inference (`type X = z.infer`) +// +// 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'; diff --git a/packages/cards-domain/src/schemas/review.ts b/packages/cards-domain/src/schemas/review.ts new file mode 100644 index 0000000..519851c --- /dev/null +++ b/packages/cards-domain/src/schemas/review.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const ReviewStateSchema = z.enum(['new', 'learning', 'review', 'relearning']); +export type ReviewState = z.infer; + +/** 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; + +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; + +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; diff --git a/packages/cards-domain/src/schemas/study.ts b/packages/cards-domain/src/schemas/study.ts new file mode 100644 index 0000000..6c15d25 --- /dev/null +++ b/packages/cards-domain/src/schemas/study.ts @@ -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; + +export const StartStudySessionInputSchema = z + .object({ + deck_id: z.string().min(1), + }) + .strict(); +export type StartStudySessionInput = z.infer; + +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; diff --git a/packages/cards-domain/src/schemas/tools.ts b/packages/cards-domain/src/schemas/tools.ts new file mode 100644 index 0000000..6e79ed9 --- /dev/null +++ b/packages/cards-domain/src/schemas/tools.ts @@ -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; + +export const CardsCreateOutputSchema = CardSchema; +export type CardsCreateOutput = z.infer; + +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; + +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; + +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; diff --git a/packages/cards-domain/src/types.ts b/packages/cards-domain/src/types.ts deleted file mode 100644 index 6d5d4b2..0000000 --- a/packages/cards-domain/src/types.ts +++ /dev/null @@ -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` 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; - -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; -}; diff --git a/packages/cards-domain/tests/fsrs.test.ts b/packages/cards-domain/tests/fsrs.test.ts new file mode 100644 index 0000000..43cb783 --- /dev/null +++ b/packages/cards-domain/tests/fsrs.test.ts @@ -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(); + }); +}); diff --git a/packages/cards-domain/tests/schemas.test.ts b/packages/cards-domain/tests/schemas.test.ts new file mode 100644 index 0000000..9610770 --- /dev/null +++ b/packages/cards-domain/tests/schemas.test.ts @@ -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); + }); +});