diff --git a/apps/api/src/db/migrations/0005_wordeck_license_rename.sql b/apps/api/src/db/migrations/0005_wordeck_license_rename.sql new file mode 100644 index 0000000..8415289 --- /dev/null +++ b/apps/api/src/db/migrations/0005_wordeck_license_rename.sql @@ -0,0 +1,36 @@ +-- Wordeck-Rebrand: License-Strings Cardecky-* → Wordeck-*. +-- +-- Bisher genutzte Lizenz-Identifier: +-- Cardecky-Personal-Use-1.0 (Free-Default) +-- Cardecky-Pro-Only-1.0 (Paid-Only) +-- +-- Diese werden bei der Wordeck-Umbenennung in die Wordeck-Variante +-- migriert (gleiche Bedingungen, neuer Brand). +-- +-- 1. CHECK-Constraint anpassen +-- 2. Bestehende Rows umbenennen +-- 3. Default-Wert der Spalte anpassen +-- +-- Idempotent (IF EXISTS / IF NOT EXISTS), damit Re-Runs harmlos sind. + +-- 1. Alten CHECK-Constraint droppen (er erwartete 'Cardecky-Pro-Only-1.0') +ALTER TABLE "marketplace"."decks" + DROP CONSTRAINT IF EXISTS "decks_price_requires_license"; + +-- 2. Bestehende Rows umbenennen (Bestand-Migration) +UPDATE "marketplace"."decks" + SET "license" = 'Wordeck-Personal-Use-1.0' + WHERE "license" = 'Cardecky-Personal-Use-1.0'; + +UPDATE "marketplace"."decks" + SET "license" = 'Wordeck-Pro-Only-1.0' + WHERE "license" = 'Cardecky-Pro-Only-1.0'; + +-- 3. Default der Spalte umstellen +ALTER TABLE "marketplace"."decks" + ALTER COLUMN "license" SET DEFAULT 'Wordeck-Personal-Use-1.0'; + +-- 4. Neuen CHECK mit Wordeck-Lizenzen +ALTER TABLE "marketplace"."decks" + ADD CONSTRAINT "decks_price_requires_license" + CHECK (price_credits = 0 OR license = 'Wordeck-Pro-Only-1.0'); diff --git a/apps/api/src/db/schema/cards.ts b/apps/api/src/db/schema/cards.ts index 84be8cb..8abc073 100644 --- a/apps/api/src/db/schema/cards.ts +++ b/apps/api/src/db/schema/cards.ts @@ -9,10 +9,11 @@ import { decks } from './decks.ts'; * * - basic / basic-reverse: { front, back } * - cloze: { text, extra? } - * - type-in: { question, expected } - * - image-occlusion: { image_ref, mask_regions: [...] } + * - typing: { front, answer, aliases? } + * - multiple-choice: { front, answer, distractor_pool?, explanation? } * - * MVP unterstützt nur `basic` und `basic-reverse`. + * Wordeck-Rebrand (2026-05-17): image-occlusion und audio-front sind + * entfallen. Wordeck ist text-only. * * `tags` ist ein eigener Tag-Mapping-Layer (siehe `tags.ts` + `cardTags`), * NICHT in dieser Tabelle gespeichert. @@ -27,7 +28,6 @@ export const cards = cardsSchema.table( 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() diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index 356ad05..252b3c2 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -22,14 +22,6 @@ export type { export { tags } from './tags.ts'; export type { TagRow, TagInsert } from './tags.ts'; -export { mediaRefs, mediaFiles } from './media.ts'; -export type { - MediaRefRow, - MediaRefInsert, - MediaFileRow, - MediaFileInsert, -} from './media.ts'; - export { importJobs } from './imports.ts'; export type { ImportJobRow, ImportJobInsert } from './imports.ts'; diff --git a/apps/api/src/db/schema/marketplace/decks.ts b/apps/api/src/db/schema/marketplace/decks.ts index 90a4bb5..f793074 100644 --- a/apps/api/src/db/schema/marketplace/decks.ts +++ b/apps/api/src/db/schema/marketplace/decks.ts @@ -9,7 +9,7 @@ * Versions-Bumps hinweg erhalten kann. * * `price_credits = 0` heißt kostenlos. Alles > 0 erzwingt die - * Cardecky-Pro-Only-1.0-Lizenz (CHECK constraint auf DB-Ebene). + * Wordeck-Pro-Only-1.0-Lizenz (CHECK constraint auf DB-Ebene). */ import { sql } from 'drizzle-orm'; @@ -39,8 +39,6 @@ export const cardTypeEnum = marketplaceSchema.enum('card_type', [ 'basic-reverse', 'cloze', 'type-in', - 'image-occlusion', - 'audio', 'multiple-choice', ]); @@ -69,8 +67,8 @@ export const publicDecks = marketplaceSchema.table( ], }), // SPDX-style ID. CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0, - // Cardecky-Personal-Use-1.0 (default für free), Cardecky-Pro-Only-1.0 (paid). - license: text('license').notNull().default('Cardecky-Personal-Use-1.0'), + // Wordeck-Personal-Use-1.0 (default für free), Wordeck-Pro-Only-1.0 (paid). + license: text('license').notNull().default('Wordeck-Personal-Use-1.0'), priceCredits: integer('price_credits').notNull().default(0), ownerUserId: text('owner_user_id') .notNull() @@ -90,7 +88,7 @@ export const publicDecks = marketplaceSchema.table( // Paid Decks müssen die Pro-Only-Lizenz tragen. priceLicense: check( 'decks_price_requires_license', - sql`price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0'` + sql`price_credits = 0 OR license = 'Wordeck-Pro-Only-1.0'` ), }) ); diff --git a/apps/api/src/db/schema/media.ts b/apps/api/src/db/schema/media.ts deleted file mode 100644 index 3941c13..0000000 --- a/apps/api/src/db/schema/media.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { index, integer, text, timestamp } from 'drizzle-orm/pg-core'; - -import { cardsSchema } from './_schema.ts'; -import { cards } from './cards.ts'; - -/** - * Media-Files: Bilder, Audio, Video, die in MinIO unter dem - * `objectKey` liegen und von Karten via cards.media_refs[] - * referenziert werden. - * - * Bewusst ohne FK auf eine konkrete Karte: ein File kann von - * mehreren Karten referenziert werden (z.B. ein Bild für Front - * und Back). Lifecycle-Cleanup per Cron oder DSGVO-Delete. - * - * objectKey-Format: `/.` — UserId-Präfix - * vereinfacht den DSGVO-Delete (Bucket-Prefix-Sweep). - */ -export const mediaFiles = cardsSchema.table( - 'media_files', - { - id: text('id').primaryKey(), - userId: text('user_id').notNull(), - objectKey: text('object_key').notNull(), - mimeType: text('mime_type').notNull(), - originalFilename: text('original_filename'), - sizeBytes: integer('size_bytes').notNull(), - kind: text('kind', { enum: ['image', 'audio', 'video', 'other'] }).notNull(), - createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }) - .notNull() - .defaultNow(), - }, - (t) => ({ - userIdx: index('media_files_user_idx').on(t.userId), - }) -); - -export type MediaFileRow = typeof mediaFiles.$inferSelect; -export type MediaFileInsert = typeof mediaFiles.$inferInsert; - -/** - * Legacy: media_refs aus Phase 1, Vor-Sprint-15. Bewusst behalten als - * Sortier-Layer-Slot für später (mana-media-Konvergenz). Aktuell leer. - */ -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/index.ts b/apps/api/src/index.ts index 5434542..2c44e8c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,9 +12,7 @@ import { toolsRouter } from './routes/tools.ts'; import { searchRouter } from './routes/search.ts'; import { dsgvoRouter } from './routes/dsgvo.ts'; import { meRouter } from './routes/me.ts'; -import { mediaRouter } from './routes/media.ts'; import { decksGenerateRouter } from './routes/decks-generate.ts'; -import { decksFromImageRouter } from './routes/decks-from-image.ts'; import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts'; import { marketplaceDecksRouter } from './routes/marketplace/decks.ts'; import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/explore.ts'; @@ -64,9 +62,7 @@ app.route('/api/v1/tools', toolsRouter()); app.route('/api/v1/search', searchRouter()); app.route('/api/v1/dsgvo', dsgvoRouter()); app.route('/api/v1/me', meRouter()); -app.route('/api/v1/media', mediaRouter()); app.route('/api/v1/decks/generate', decksGenerateRouter()); -app.route('/api/v1/decks/from-image', decksFromImageRouter()); // Marketplace (Phase 12). Eigenes pgSchema, additive Routen unter /v1/marketplace/*. // Plan: docs/playbooks/MARKETPLACE_RESTORE.md. diff --git a/apps/api/src/lib/dto.ts b/apps/api/src/lib/dto.ts index fa1570e..ace4149 100644 --- a/apps/api/src/lib/dto.ts +++ b/apps/api/src/lib/dto.ts @@ -26,7 +26,6 @@ export function toCardDto(row: typeof cards.$inferSelect) { 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/cards.ts b/apps/api/src/routes/cards.ts index cff6ddc..859323e 100644 --- a/apps/api/src/routes/cards.ts +++ b/apps/api/src/routes/cards.ts @@ -5,7 +5,6 @@ import { CardCreateSchema, CardUpdateSchema, cardContentHash, - maskRegionCount, subIndexCount, subIndexCountForCloze, } from '@cards/domain'; @@ -43,9 +42,9 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> } const userId = c.get('userId'); - // Text-abhängige Sub-Index-Counts (Cloze, Image-Occlusion) vor - // dem Deck-Lookup auflösen — Validation-Errors bleiben 422 statt - // versehentlich auf 404 zu fallen. + // Text-abhängige Sub-Index-Counts (Cloze) vor dem Deck-Lookup + // auflösen — Validation-Errors bleiben 422 statt versehentlich + // auf 404 zu fallen. let count: number; if (parsed.data.type === 'cloze') { count = subIndexCountForCloze(parsed.data.fields.text ?? ''); @@ -55,17 +54,6 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> 422 ); } - } else if (parsed.data.type === 'image-occlusion') { - count = maskRegionCount(parsed.data.fields.mask_regions ?? ''); - if (count === 0) { - return c.json( - { - error: 'invalid_input', - issues: ['image-occlusion.mask_regions must be a JSON array with >=1 valid region'], - }, - 422 - ); - } } else { count = subIndexCount(parsed.data.type); } @@ -95,7 +83,6 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> userId, type: parsed.data.type, fields: parsed.data.fields, - mediaRefs: parsed.data.media_refs ?? [], contentHash, createdAt: now, updatedAt: now, @@ -168,7 +155,6 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> .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))) diff --git a/apps/api/src/routes/decks-from-image.ts b/apps/api/src/routes/decks-from-image.ts deleted file mode 100644 index db6c582..0000000 --- a/apps/api/src/routes/decks-from-image.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { Hono } from 'hono'; -import { z } from 'zod'; - -import { getDb, type CardsDb } from '../db/connection.ts'; -import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; -import { rateLimit, userKey } from '../middleware/rate-limit.ts'; -import { chatVisionJson } from '../services/llm-client.ts'; -import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts'; -import { fetchUrlContent } from '../lib/url-fetch.ts'; - -export type FromImageDeps = { db?: CardsDb }; - -const MAX_FILES = 5; -const MAX_BYTES_PER_IMAGE = 10 * 1024 * 1024; -const MAX_BYTES_PER_PDF = 30 * 1024 * 1024; - -function isAllowedMime(mime: string): boolean { - return mime.startsWith('image/') || mime === 'application/pdf'; -} - -function maxBytesFor(mime: string): number { - return mime === 'application/pdf' ? MAX_BYTES_PER_PDF : MAX_BYTES_PER_IMAGE; -} - -const InputSchema = z.object({ - language: z.enum(['de', 'en']).optional().default('de'), - count: z.coerce.number().int().min(1).max(40).optional().default(15), - url: z.string().url().max(2000).optional(), -}); - -const SYSTEM_PROMPT = `Du bist ein Lerndesigner. Analysiere alle bereitgestellten Inhalte (Bilder, Dokumente, Texte, URLs) und erstelle daraus ein einziges zusammenhängendes Karteikarten-Deck für Spaced-Repetition-Lernen. - -Du gibst NUR ein gültiges JSON-Objekt zurück, exakt mit diesem Schema: -{ - "deck_name": "", - "deck_description": "", - "cards": [ - { "front": "", "back": "" }, - ... - ] -} - -Regeln: -- Front ist Frage / Begriff / Hinweis. Back ist Antwort / Definition / Erklärung. -- Eine Karte = ein Lernstoff-Bissen (atomic). Nicht mehrere Konzepte in eine Karte stopfen. -- Markdown ist erlaubt (**fett**, *kursiv*, Listen, \`code\`). -- KEIN HTML, KEIN Code-Fence außerhalb des JSON, KEINE Erklärung außerhalb des JSON. -- Erstelle ein kohärentes Deck, das den Lernstoff aller Quellen zusammenfasst.`; - -export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables: AuthVars }> { - const r = new Hono<{ Variables: AuthVars }>(); - const dbOf = () => deps.db ?? getDb(); - - r.use('*', authMiddleware); - // 10/min per User — Vision-LLM-Call ist besonders teuer. - r.use('/', rateLimit({ scope: 'decks.from-image', windowMs: 60_000, max: 10, keyOf: userKey })); - - r.post('/', async (c) => { - const userId = c.get('userId'); - - // Accept multipart (files + optional url) OR json (url-only, no files) - const contentType = c.req.header('Content-Type') ?? ''; - let files: File[] = []; - let rawInput: { language?: unknown; count?: unknown; url?: unknown } = {}; - - if (contentType.includes('multipart/form-data')) { - const form = await c.req.formData().catch(() => null); - if (!form) { - return c.json({ error: 'invalid_input', detail: 'multipart body required' }, 400); - } - const rawFiles = form.getAll('file'); - files = rawFiles.filter((f): f is File => f instanceof File && isAllowedMime(f.type)); - rawInput = { - language: form.get('language') ?? undefined, - count: form.get('count') ?? undefined, - url: form.get('url') ?? undefined, - }; - } else { - const body = await c.req.json().catch(() => null); - if (!body || typeof body !== 'object') { - return c.json({ error: 'invalid_input', detail: 'json or multipart body required' }, 400); - } - rawInput = body as { language?: unknown; count?: unknown; url?: unknown }; - } - - const parsed = InputSchema.safeParse(rawInput); - if (!parsed.success) { - return c.json( - { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, - 422, - ); - } - const { language, count, url } = parsed.data; - - if (files.length === 0 && !url) { - return c.json( - { error: 'invalid_input', detail: 'provide at least one image/PDF file or a URL' }, - 400, - ); - } - if (files.length > MAX_FILES) { - return c.json({ error: 'invalid_input', detail: `max ${MAX_FILES} files per request` }, 400); - } - const oversized = files.find((f) => f.size > maxBytesFor(f.type)); - if (oversized) { - const limit = oversized.type === 'application/pdf' ? '30 MiB' : '10 MiB'; - return c.json( - { error: 'invalid_input', detail: `"${oversized.name}" exceeds ${limit} limit` }, - 413, - ); - } - - const [images, urlContent] = await Promise.all([ - Promise.all( - files.map(async (f) => ({ - base64: Buffer.from(await f.arrayBuffer()).toString('base64'), - mimeType: f.type, - })), - ), - url ? fetchUrlContent(url) : Promise.resolve(null), - ]); - - // URL-only request where extraction failed → can't generate anything meaningful - if (url && images.length === 0 && !urlContent) { - return c.json( - { error: 'url_extraction_failed', detail: `could not extract content from ${url}` }, - 502, - ); - } - - const sourceParts: string[] = []; - if (images.length > 0) { - const hasPdf = files.some((f) => f.type === 'application/pdf'); - sourceParts.push( - hasPdf - ? images.length === 1 ? 'einem Dokument' : `${images.length} Dateien` - : images.length === 1 ? 'einem Bild' : `${images.length} Bildern`, - ); - } - if (urlContent) sourceParts.push('URL-Inhalt'); - - const langLabel = language === 'de' ? 'Deutsch' : 'English'; - let userText = `Erstelle ${count} Lernkarten auf ${langLabel} aus ${sourceParts.join(' und ')}.`; - - if (urlContent) { - userText += `\n\nURL-Kontext (${url}):\n${urlContent}`; - } - - let generated: z.infer; - try { - const raw = await chatVisionJson({ - images, - systemPrompt: SYSTEM_PROMPT, - userText, - timeoutMs: 120_000, - }); - const r2 = GeneratedDeckSchema.safeParse(raw); - if (!r2.success) { - return c.json( - { - error: 'llm_returned_invalid_shape', - issues: r2.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`), - raw, - }, - 502, - ); - } - generated = r2.data; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return c.json({ error: 'llm_call_failed', detail: msg }, 502); - } - - const fallbackParts: string[] = []; - if (images.length > 0) { - const hasPdf = files.some((f) => f.type === 'application/pdf'); - fallbackParts.push( - hasPdf - ? images.length === 1 ? 'Dokument' : `${images.length} Dateien` - : images.length === 1 ? 'Bild' : `${images.length} Bildern`, - ); - } - if (url) fallbackParts.push('URL'); - const fallback = `KI-generiert aus ${fallbackParts.join(' + ')}`; - - const result = await insertGeneratedDeck(dbOf(), userId, generated, fallback); - return c.json(result, 201); - }); - - return r; -} diff --git a/apps/api/src/routes/decks-generate.ts b/apps/api/src/routes/decks-generate.ts index 1f8c757..7f62a9f 100644 --- a/apps/api/src/routes/decks-generate.ts +++ b/apps/api/src/routes/decks-generate.ts @@ -70,7 +70,6 @@ export async function insertGeneratedDeck( userId, type: 'basic', fields: cr.fields, - mediaRefs: [], contentHash: cr.contentHash, createdAt: now, updatedAt: now, diff --git a/apps/api/src/routes/decks.ts b/apps/api/src/routes/decks.ts index 8bee455..cd2f6ba 100644 --- a/apps/api/src/routes/decks.ts +++ b/apps/api/src/routes/decks.ts @@ -185,7 +185,6 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }> userId, type: card.type, fields: card.fields as Record, - mediaRefs: card.mediaRefs, contentHash: card.contentHash, createdAt: now, updatedAt: now, diff --git a/apps/api/src/routes/dsgvo.ts b/apps/api/src/routes/dsgvo.ts index 911c916..c46b3c3 100644 --- a/apps/api/src/routes/dsgvo.ts +++ b/apps/api/src/routes/dsgvo.ts @@ -6,15 +6,12 @@ import { cards, decks, importJobs, - mediaFiles, - mediaRefs, reviews, studySessions, tags, } from '../db/schema/index.ts'; import { serviceKeyAuth } from '../middleware/service-key.ts'; import { rateLimit, ipKey } from '../middleware/rate-limit.ts'; -import { getStorage } from '../services/storage.ts'; import { auditLog } from '../lib/audit.ts'; export type DsgvoDeps = { db?: CardsDb }; @@ -31,8 +28,6 @@ export async function buildUserExport(db: CardsDb, userId: string) { reviewsRows, sessionsRows, tagsRows, - mediaRefRows, - mediaFileRows, importsRows, ] = await Promise.all([ db.select().from(decks).where(eq(decks.userId, userId)), @@ -40,15 +35,13 @@ export async function buildUserExport(db: CardsDb, userId: string) { db.select().from(reviews).where(eq(reviews.userId, userId)), db.select().from(studySessions).where(eq(studySessions.userId, userId)), db.select().from(tags).where(eq(tags.userId, userId)), - db.select().from(mediaRefs).where(eq(mediaRefs.userId, userId)), - db.select().from(mediaFiles).where(eq(mediaFiles.userId, userId)), db.select().from(importJobs).where(eq(importJobs.userId, userId)), ]); return { user_id: userId, exported_at: new Date().toISOString(), - app: 'cards', + app: 'wordeck', app_version: process.env.CARDS_API_VERSION ?? '0.0.0', data: { decks: decksRows.map((d) => ({ @@ -72,14 +65,6 @@ export async function buildUserExport(db: CardsDb, userId: string) { finishedAt: s.finishedAt ? s.finishedAt.toISOString() : null, })), tags: tagsRows.map((t) => ({ ...t, createdAt: t.createdAt.toISOString() })), - media_refs: mediaRefRows.map((m) => ({ - ...m, - createdAt: m.createdAt.toISOString(), - })), - media_files: mediaFileRows.map((f) => ({ - ...f, - createdAt: f.createdAt.toISOString(), - })), import_jobs: importsRows.map((j) => ({ ...j, createdAt: j.createdAt.toISOString(), @@ -130,16 +115,13 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono { }); /** - * Vollständige Löschung aller Cards-Daten eines Users. FK-Cascades + * Vollständige Löschung aller Wordeck-Daten eines Users. FK-Cascades * räumen automatisch: - * decks → cards → reviews - * cards → media_refs - * cards → card_tags + * decks → cards → reviews / card_tags * decks → tags * decks → study_sessions - * Verbleibend: import_jobs + media_files (eigene Tabellen ohne FK) - * — werden separat gelöscht. MinIO-Objects werden per Bucket-Prefix- - * Sweep entfernt (objectKey-Format `/.`). + * Verbleibend: import_jobs (eigene Tabelle ohne FK) — wird separat + * gelöscht. Kein Object-Storage mehr (text-only ab Wordeck-Rebrand). */ r.post('/delete', async (c) => { const body = await c.req.json().catch(() => null); @@ -147,39 +129,17 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono { if (!userId) return c.json({ error: 'missing_user_id' }, 400); const db = dbOf(); - const [deletedDecks, deletedImports, deletedMediaFiles] = await db.transaction( - async (tx) => { - const dd = await tx - .delete(decks) - .where(eq(decks.userId, userId)) - .returning({ id: decks.id }); - const di = await tx - .delete(importJobs) - .where(eq(importJobs.userId, userId)) - .returning({ id: importJobs.id }); - const dm = await tx - .delete(mediaFiles) - .where(eq(mediaFiles.userId, userId)) - .returning({ id: mediaFiles.id }); - return [dd, di, dm]; - } - ); - - // MinIO-Bucket-Sweep nach DB-Cleanup. Wenn der Storage-Sweep - // scheitert, ist das nicht-fatal für die DB-Konsistenz (Files - // ohne DB-Eintrag sind tote Bytes), aber der Caller MUSS es - // erfahren — sonst meldet mana-admin dem User „alles gelöscht" - // obwohl Media-Objekte überleben können. - let storageObjectsDeleted = 0; - let storageOk = true; - let storageError: string | null = null; - try { - storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`); - } catch (err) { - storageOk = false; - storageError = err instanceof Error ? err.message : String(err); - console.warn('[dsgvo/delete] storage sweep failed:', err); - } + const [deletedDecks, deletedImports] = await db.transaction(async (tx) => { + const dd = await tx + .delete(decks) + .where(eq(decks.userId, userId)) + .returning({ id: decks.id }); + const di = await tx + .delete(importJobs) + .where(eq(importJobs.userId, userId)) + .returning({ id: importJobs.id }); + return [dd, di]; + }); auditLog({ event: 'dsgvo.delete', @@ -187,27 +147,19 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono { auth_mode: 'service-key', ip: c.req.header('X-Forwarded-For') ?? null, user_agent: c.req.header('User-Agent') ?? null, - result: storageOk ? 'success' : 'partial', + result: 'success', detail: { decks: deletedDecks.length, import_jobs: deletedImports.length, - media_files: deletedMediaFiles.length, - storage_objects: storageObjectsDeleted, - storage_ok: storageOk, - storage_error: storageError, }, }); return c.json({ deleted: true, user_id: userId, - storage_ok: storageOk, - ...(storageError ? { storage_error: storageError } : {}), counts: { decks: deletedDecks.length, import_jobs: deletedImports.length, - media_files: deletedMediaFiles.length, - storage_objects: storageObjectsDeleted, }, }); }); diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts index a441acf..1aa86fa 100644 --- a/apps/api/src/routes/health.ts +++ b/apps/api/src/routes/health.ts @@ -2,38 +2,33 @@ import { Hono } from 'hono'; import { sql } from 'drizzle-orm'; import { getDb } from '../db/connection.ts'; -import { getStorage } from '../services/storage.ts'; export const healthRoute = new Hono(); /** * Liveness — antwortet immer 200, solange der Prozess läuft. - * Bewusst KEIN Downstream-Probe, damit ein kurzer DB-/MinIO-Hänger + * Bewusst KEIN Downstream-Probe, damit ein kurzer DB-Hänger * nicht den Container in eine Restart-Schleife zwingt. */ healthRoute.get('/healthz', (c) => c.json({ status: 'ok' })); /** - * Readiness mit Downstream-Probes. Status 200 wenn DB + MinIO grün, + * Readiness mit Downstream-Probes. Status 200 wenn DB grün, * sonst 503 mit Aufschlüsselung welche Probe fehlgeschlagen ist. * Probes timen sich selbst aus über ein 1s-AbortSignal — eine träge * Abhängigkeit darf das Readiness-Signal nicht hängen lassen. */ healthRoute.get('/healthz/details', async (c) => { - const [dbProbe, storageProbe] = await Promise.all([ - probeDb(), - probeStorage(), - ]); - const allOk = dbProbe.ok && storageProbe.ok; + const dbProbe = await probeDb(); + const allOk = dbProbe.ok; return c.json( { status: allOk ? 'ok' : 'degraded', - app: 'cards', + app: 'wordeck', version: process.env.CARDS_API_VERSION ?? '0.0.0', uptime_s: Math.floor(process.uptime()), checks: { db: dbProbe, - storage: storageProbe, }, }, allOk ? 200 : 503 @@ -42,7 +37,7 @@ healthRoute.get('/healthz/details', async (c) => { healthRoute.get('/version', (c) => c.json({ - app: 'cards', + app: 'wordeck', version: process.env.CARDS_API_VERSION ?? '0.0.0', build: process.env.CARDS_BUILD_SHA ?? 'dev', }) @@ -61,16 +56,6 @@ async function probeDb(): Promise { } } -async function probeStorage(): Promise { - const t0 = Date.now(); - try { - await getStorage().ensureBucket(); - return { ok: true, latency_ms: Date.now() - t0 }; - } catch (err) { - return { ok: false, error: errorMessage(err) }; - } -} - function errorMessage(err: unknown): string { if (err instanceof Error) return err.message; return String(err); diff --git a/apps/api/src/routes/marketplace/decks.ts b/apps/api/src/routes/marketplace/decks.ts index 585cb4d..02271ea 100644 --- a/apps/api/src/routes/marketplace/decks.ts +++ b/apps/api/src/routes/marketplace/decks.ts @@ -74,8 +74,6 @@ const CardTypeSchema = z.enum([ 'basic-reverse', 'cloze', 'type-in', - 'image-occlusion', - 'audio', 'multiple-choice', ]); @@ -178,13 +176,13 @@ export function marketplaceDecksRouter( return c.json({ error: 'invalid_slug', reason: validation.reason }, 422); } - const license = parsed.data.license ?? 'Cardecky-Personal-Use-1.0'; + const license = parsed.data.license ?? 'Wordeck-Personal-Use-1.0'; const priceCredits = parsed.data.priceCredits ?? 0; - if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') { + if (priceCredits > 0 && license !== 'Wordeck-Pro-Only-1.0') { return c.json( { error: 'paid_decks_require_pro_license', - detail: 'priceCredits > 0 ⇒ license must be Cardecky-Pro-Only-1.0', + detail: 'priceCredits > 0 ⇒ license must be Wordeck-Pro-Only-1.0', }, 422 ); @@ -248,7 +246,7 @@ export function marketplaceDecksRouter( const license = parsed.data.license ?? deck.license; const priceCredits = parsed.data.priceCredits ?? deck.priceCredits; - if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') { + if (priceCredits > 0 && license !== 'Wordeck-Pro-Only-1.0') { return c.json({ error: 'paid_decks_require_pro_license' }, 422); } diff --git a/apps/api/src/routes/marketplace/fork.ts b/apps/api/src/routes/marketplace/fork.ts index 513cba4..d7bc20f 100644 --- a/apps/api/src/routes/marketplace/fork.ts +++ b/apps/api/src/routes/marketplace/fork.ts @@ -124,7 +124,6 @@ export async function forkDeckForUser( userId, type: sourceCard.type, fields: sourceCard.fields, - mediaRefs: [], contentHash: sourceCard.contentHash, createdAt: now, updatedAt: now, @@ -342,7 +341,6 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au userId, type: card.type, fields: card.fields, - mediaRefs: [], contentHash: computedHash, createdAt: now, updatedAt: now, diff --git a/apps/api/src/routes/marketplace/pull-requests.ts b/apps/api/src/routes/marketplace/pull-requests.ts index 170d123..1edbcbc 100644 --- a/apps/api/src/routes/marketplace/pull-requests.ts +++ b/apps/api/src/routes/marketplace/pull-requests.ts @@ -48,8 +48,6 @@ const CardTypeEnum = z.enum([ 'basic-reverse', 'cloze', 'type-in', - 'image-occlusion', - 'audio', 'multiple-choice', ]); @@ -335,8 +333,6 @@ export function pullRequestsRouter( | 'basic-reverse' | 'cloze' | 'type-in' - | 'image-occlusion' - | 'audio' | 'multiple-choice', fields: card.fields, ord: card.ord, diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 431e328..8aa3d28 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -2,9 +2,8 @@ import { and, desc, eq, gte, isNotNull, lte, sql } from 'drizzle-orm'; import { Hono } from 'hono'; import { getDb, type CardsDb } from '../db/connection.ts'; -import { cards, decks, importJobs, mediaFiles, reviews } from '../db/schema/index.ts'; +import { cards, decks, importJobs, reviews } from '../db/schema/index.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; -import { getStorage } from '../services/storage.ts'; import { buildUserExport } from './dsgvo.ts'; import { auditLog } from '../lib/audit.ts'; @@ -19,15 +18,11 @@ function frontSnippetFor(type: string, fields: Record): string { const raw = type === 'cloze' ? (fields.text ?? '') - : type === 'image-occlusion' - ? '[Bild-Karte]' - : type === 'audio-front' - ? (fields.front ?? '[Audio-Karte]') - : type === 'typing' - ? (fields.question ?? fields.front ?? '') - : type === 'multiple-choice' - ? (fields.question ?? fields.front ?? '') - : (fields.front ?? ''); + : type === 'typing' + ? (fields.question ?? fields.front ?? '') + : type === 'multiple-choice' + ? (fields.question ?? fields.front ?? '') + : (fields.front ?? ''); const cleaned = raw .replace(/\{\{c\d+::([^:}]*)(?:::[^}]*)?\}\}/g, '$1') .replace(/[*_`#>]/g, '') @@ -353,34 +348,17 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { r.post('/delete', async (c) => { const userId = c.get('userId'); const db = dbOf(); - const [deletedDecks, deletedImports, deletedMediaFiles] = await db.transaction( - async (tx) => { - const dd = await tx - .delete(decks) - .where(eq(decks.userId, userId)) - .returning({ id: decks.id }); - const di = await tx - .delete(importJobs) - .where(eq(importJobs.userId, userId)) - .returning({ id: importJobs.id }); - const dm = await tx - .delete(mediaFiles) - .where(eq(mediaFiles.userId, userId)) - .returning({ id: mediaFiles.id }); - return [dd, di, dm]; - } - ); - - let storageObjectsDeleted = 0; - let storageOk = true; - let storageError: string | null = null; - try { - storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`); - } catch (err) { - storageOk = false; - storageError = err instanceof Error ? err.message : String(err); - console.warn('[me/delete] storage sweep failed:', err); - } + const [deletedDecks, deletedImports] = await db.transaction(async (tx) => { + const dd = await tx + .delete(decks) + .where(eq(decks.userId, userId)) + .returning({ id: decks.id }); + const di = await tx + .delete(importJobs) + .where(eq(importJobs.userId, userId)) + .returning({ id: importJobs.id }); + return [dd, di]; + }); auditLog({ event: 'dsgvo.delete', @@ -389,27 +367,19 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { auth_mode: c.get('authMode'), ip: c.req.header('X-Forwarded-For') ?? null, user_agent: c.req.header('User-Agent') ?? null, - result: storageOk ? 'success' : 'partial', + result: 'success', detail: { decks: deletedDecks.length, import_jobs: deletedImports.length, - media_files: deletedMediaFiles.length, - storage_objects: storageObjectsDeleted, - storage_ok: storageOk, - storage_error: storageError, }, }); return c.json({ deleted: true, user_id: userId, - storage_ok: storageOk, - ...(storageError ? { storage_error: storageError } : {}), counts: { decks: deletedDecks.length, import_jobs: deletedImports.length, - media_files: deletedMediaFiles.length, - storage_objects: storageObjectsDeleted, }, }); }); diff --git a/apps/api/src/routes/media.ts b/apps/api/src/routes/media.ts deleted file mode 100644 index 154fd1f..0000000 --- a/apps/api/src/routes/media.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { and, eq } from 'drizzle-orm'; -import { Hono } from 'hono'; - -import { getDb, type CardsDb } from '../db/connection.ts'; -import { mediaFiles, type MediaFileRow } from '../db/schema/index.ts'; -import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; -import { rateLimit, userKey } from '../middleware/rate-limit.ts'; -import { ulid } from '../lib/ulid.ts'; -import { getStorage, type StorageService } from '../services/storage.ts'; - -export type MediaDeps = { db?: CardsDb; storage?: StorageService }; - -const MAX_BYTES = Number(process.env.CARDS_MEDIA_MAX_BYTES ?? 25 * 1024 * 1024); // 25 MiB -const ALLOWED_PREFIXES = ['image/', 'audio/', 'video/']; - -function kindFor(mime: string): MediaFileRow['kind'] { - if (mime.startsWith('image/')) return 'image'; - if (mime.startsWith('audio/')) return 'audio'; - if (mime.startsWith('video/')) return 'video'; - return 'other'; -} - -function extFor(mime: string, fallback?: string): string { - const map: Record = { - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/gif': 'gif', - 'image/webp': 'webp', - 'image/svg+xml': 'svg', - 'audio/mpeg': 'mp3', - 'audio/ogg': 'ogg', - 'audio/wav': 'wav', - 'audio/mp4': 'm4a', - 'video/mp4': 'mp4', - 'video/webm': 'webm', - }; - if (map[mime]) return map[mime]; - if (fallback) { - const dot = fallback.lastIndexOf('.'); - if (dot > 0 && dot < fallback.length - 1) return fallback.slice(dot + 1).toLowerCase(); - } - return 'bin'; -} - -export function mediaRouter(deps: MediaDeps = {}): Hono<{ Variables: AuthVars }> { - const r = new Hono<{ Variables: AuthVars }>(); - const dbOf = () => deps.db ?? getDb(); - const storageOf = () => deps.storage ?? getStorage(); - - r.use('*', authMiddleware); - // 30/min per User — Upload ist teuer (Storage + DB-Insert), aber - // ein Anki-Import kann viele Files in Folge schicken; das Limit - // soll Bursts ermöglichen, aber Dauer-Spam stoppen. - r.use('/upload', rateLimit({ scope: 'media.upload', windowMs: 60_000, max: 30, keyOf: userKey })); - - /** - * Multipart-Upload eines einzelnen Files. Bilder/Audio/Video; alles - * andere wird mit 415 abgelehnt. Limit per env (Default 25 MiB). - * - * Antwort: - * { id, url, mime_type, kind, size_bytes, original_filename } - * - * `url` ist ein relativer App-Pfad (`/api/v1/media/`), den - * Frontend + Anki-Importer in `` einsetzen können. - * Public absolute URL kommt erst mit Phase 10 + DNS dazu. - */ - r.post('/upload', async (c) => { - const userId = c.get('userId'); - const form = await c.req.formData().catch(() => null); - if (!form) return c.json({ error: 'expected_multipart' }, 400); - - const file = form.get('file'); - if (!(file instanceof File)) return c.json({ error: 'missing_file_field' }, 400); - - const mime = file.type || 'application/octet-stream'; - if (!ALLOWED_PREFIXES.some((p) => mime.startsWith(p))) { - return c.json({ error: 'unsupported_media_type', mime_type: mime }, 415); - } - if (file.size > MAX_BYTES) { - return c.json({ error: 'too_large', max_bytes: MAX_BYTES, got: file.size }, 413); - } - - const id = ulid(); - const ext = extFor(mime, file.name); - const objectKey = `${userId}/${id}.${ext}`; - const buf = new Uint8Array(await file.arrayBuffer()); - - await storageOf().putObject(objectKey, buf, mime); - - const [row] = await dbOf() - .insert(mediaFiles) - .values({ - id, - userId, - objectKey, - mimeType: mime, - originalFilename: file.name || null, - sizeBytes: buf.byteLength, - kind: kindFor(mime), - createdAt: new Date(), - }) - .returning(); - - return c.json( - { - id: row.id, - url: `/api/v1/media/${row.id}`, - mime_type: row.mimeType, - kind: row.kind, - size_bytes: row.sizeBytes, - original_filename: row.originalFilename, - }, - 201 - ); - }); - - /** - * Streamt ein Media-File via MinIO-getObject. User-gated — fremde - * Files sind 404 (nicht 403, damit IDs nicht enumerierbar sind). - */ - r.get('/:id', async (c) => { - const userId = c.get('userId'); - const id = c.req.param('id'); - const [row] = await dbOf() - .select() - .from(mediaFiles) - .where(and(eq(mediaFiles.id, id), eq(mediaFiles.userId, userId))) - .limit(1); - if (!row) return c.json({ error: 'not_found' }, 404); - - const stream = await storageOf().getObjectStream(row.objectKey); - c.header('Content-Type', row.mimeType); - c.header('Content-Length', String(row.sizeBytes)); - c.header('Cache-Control', 'private, max-age=31536000, immutable'); - - return new Response(stream as unknown as ReadableStream, { - status: 200, - headers: c.res.headers, - }); - }); - - /** Listet alle Media-Files des Users — nützlich fürs UI später. */ - r.get('/', async (c) => { - const userId = c.get('userId'); - const rows = await dbOf().select().from(mediaFiles).where(eq(mediaFiles.userId, userId)); - return c.json({ - files: rows.map((r) => ({ - id: r.id, - url: `/api/v1/media/${r.id}`, - mime_type: r.mimeType, - kind: r.kind, - size_bytes: r.sizeBytes, - original_filename: r.originalFilename, - created_at: r.createdAt.toISOString(), - })), - total: rows.length, - }); - }); - - return r; -} diff --git a/apps/api/src/routes/tools.ts b/apps/api/src/routes/tools.ts index 4ef1195..d6f936e 100644 --- a/apps/api/src/routes/tools.ts +++ b/apps/api/src/routes/tools.ts @@ -5,7 +5,6 @@ import { CardsCreateInputSchema, CardsSearchInputSchema, cardContentHash, - maskRegionCount, subIndexCount, subIndexCountForCloze, } from '@cards/domain'; @@ -18,7 +17,7 @@ import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; import { ulid } from '../lib/ulid.ts'; import { searchUserCards } from '../lib/search.ts'; -const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://cardecky.mana.how'; +const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://wordeck.com'; const APP_VERSION = process.env.CARDS_API_VERSION ?? '0.0.0'; export type ToolsDeps = { db?: CardsDb }; @@ -60,8 +59,7 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }> if (deck.userId !== userId) return c.json({ error: 'deck_not_owned' }, 403); // Text-abhängige Sub-Index-Counts identisch zum REST-Pfad - // (cards.ts POST). Cloze ohne Cluster + Image-Occlusion - // ohne Mask-Regions werden 422. + // (cards.ts POST). Cloze ohne Cluster wird 422. let count: number; if (parsed.data.type === 'cloze') { count = subIndexCountForCloze(parsed.data.fields.text ?? ''); @@ -71,17 +69,6 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }> 422 ); } - } else if (parsed.data.type === 'image-occlusion') { - count = maskRegionCount(parsed.data.fields.mask_regions ?? ''); - if (count === 0) { - return c.json( - { - error: 'invalid_input', - issues: ['image-occlusion.mask_regions must be JSON array with >=1 region'], - }, - 422 - ); - } } else { count = subIndexCount(parsed.data.type); } @@ -101,7 +88,6 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }> userId, type: parsed.data.type, fields: parsed.data.fields, - mediaRefs: parsed.data.media_refs ?? [], contentHash, createdAt: now, updatedAt: now, @@ -118,7 +104,6 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }> 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/services/storage.ts b/apps/api/src/services/storage.ts deleted file mode 100644 index a4d98ec..0000000 --- a/apps/api/src/services/storage.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Object-Storage über MinIO (S3-API-kompatibel). - * - * Lokal: Container `cards-minio` (siehe infrastructure/docker-compose.yml) - * auf 9100/9101 — Plattform-MinIO bleibt auf 9000/9001 ungestört. - * - * Produktiv (Phase 10): entweder eigener MinIO auf dem Mac Mini mit - * separatem Bucket, oder gegen das Plattform-MinIO mit eigenem Bucket - * `cards-media`. Konfiguration via env, kein Code-Pfad muss umgebogen - * werden. - */ - -import * as Minio from 'minio'; - -let cached: StorageService | null = null; - -export class StorageService { - readonly client: Minio.Client; - readonly bucket: string; - private bucketReady = false; - - constructor() { - this.client = new Minio.Client({ - endPoint: process.env.CARDS_S3_ENDPOINT ?? 'localhost', - port: Number(process.env.CARDS_S3_PORT ?? 9100), - useSSL: process.env.CARDS_S3_USE_SSL === 'true', - accessKey: process.env.CARDS_S3_ACCESS_KEY ?? 'cardsadmin', - secretKey: process.env.CARDS_S3_SECRET_KEY ?? 'cardsadmin', - }); - this.bucket = process.env.CARDS_S3_BUCKET ?? 'cards-media'; - } - - /** Idempotenter Bucket-Init. Wird einmal pro Process-Lifetime gerufen. */ - async ensureBucket(): Promise { - if (this.bucketReady) return; - const exists = await this.client.bucketExists(this.bucket).catch(() => false); - if (!exists) { - await this.client.makeBucket(this.bucket); - } - this.bucketReady = true; - } - - async putObject( - key: string, - body: Buffer | Uint8Array, - contentType: string - ): Promise { - await this.ensureBucket(); - await this.client.putObject(this.bucket, key, Buffer.from(body), body.byteLength, { - 'Content-Type': contentType, - }); - } - - async getObjectStream(key: string): Promise { - await this.ensureBucket(); - return this.client.getObject(this.bucket, key); - } - - async statObject(key: string): Promise<{ size: number; contentType: string }> { - await this.ensureBucket(); - const stat = await this.client.statObject(this.bucket, key); - return { - size: stat.size, - contentType: stat.metaData?.['content-type'] ?? 'application/octet-stream', - }; - } - - async removeObject(key: string): Promise { - await this.ensureBucket(); - await this.client.removeObject(this.bucket, key); - } - - async removeObjectsByPrefix(prefix: string): Promise { - await this.ensureBucket(); - const objectsStream = this.client.listObjectsV2(this.bucket, prefix, true); - const keys: string[] = []; - for await (const obj of objectsStream) { - if (obj.name) keys.push(obj.name); - } - if (keys.length > 0) await this.client.removeObjects(this.bucket, keys); - return keys.length; - } -} - -export function getStorage(): StorageService { - if (!cached) cached = new StorageService(); - return cached; -} - -export function resetStorageForTests(): void { - cached = null; -} diff --git a/apps/api/src/share-handlers/index.ts b/apps/api/src/share-handlers/index.ts index 54ada9e..0cb1697 100644 --- a/apps/api/src/share-handlers/index.ts +++ b/apps/api/src/share-handlers/index.ts @@ -15,7 +15,7 @@ export type HandlerResult = { resulting_id: string; }; -const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://cardecky.mana.how'; +const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://wordeck.com'; /** * Legt eine basic-Karte mit (front,back) im Inbox-Deck an, inkl. @@ -38,7 +38,6 @@ async function persistCardInInbox( userId, type: 'basic', fields: { front, back }, - mediaRefs: [], createdAt: now, updatedAt: now, }); diff --git a/apps/api/tests/media.test.ts b/apps/api/tests/media.test.ts deleted file mode 100644 index 2607295..0000000 --- a/apps/api/tests/media.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Hono } from 'hono'; - -import { mediaRouter } from '../src/routes/media.ts'; -import type { CardsDb } from '../src/db/connection.ts'; - -/** - * Auth-Gate-Tests für die Media-Routen ohne echte DB. Wir prüfen, dass - * der authMiddleware-Pfad ehrt und Validation-Errors konsistent sind. - * Ein echter MinIO-Roundtrip bleibt dem manuellen E2E-Smoke vorbehalten, - * weil sql.js + JSZip + MinIO-SDK in Vitest zu viel Mock-Overhead wäre. - */ -function buildApp() { - const app = new Hono(); - const stub = {} as CardsDb; - app.route('/api/v1/media', mediaRouter({ db: stub })); - return { app }; -} - -describe('mediaRouter — auth-gate', () => { - it('GET ohne X-User-Id ist 401', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/media'); - expect(res.status).toBe(401); - }); - - it('GET /:id ohne X-User-Id ist 401', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/media/abc'); - expect(res.status).toBe(401); - }); - - it('POST /upload ohne X-User-Id ist 401', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/media/upload', { - method: 'POST', - }); - expect(res.status).toBe(401); - }); -}); - -describe('mediaRouter — Input-Validation', () => { - it('POST /upload ohne multipart-Body ist 400', async () => { - const { app } = buildApp(); - const res = await app.request('/api/v1/media/upload', { - method: 'POST', - headers: { 'X-User-Id': 'u-1' }, - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('expected_multipart'); - }); -}); diff --git a/apps/web/src/lib/anki/import.ts b/apps/web/src/lib/anki/import.ts index a834f97..446f8e1 100644 --- a/apps/web/src/lib/anki/import.ts +++ b/apps/web/src/lib/anki/import.ts @@ -1,128 +1,38 @@ /** * Server-authoritative Anki-Import. * - * Schreibt gegen die cards-api HTTP-Endpoints — keine Dexie, keine - * lokalen Stores. Anki-Decks werden 1:1 in cards-Decks gemappt - * (Anki-`::` zu ` / ` flacht die Hierarchie aus, wie im Original). - * Karten werden mit sanitisiertem Markdown angelegt. + * Schreibt gegen die wordeck-api HTTP-Endpoints — keine Dexie, keine + * lokalen Stores. Anki-Decks werden 1:1 in Decks gemappt (Anki-`::` + * zu ` / ` flacht die Hierarchie aus, wie im Original). Karten werden + * mit sanitisiertem Markdown angelegt. * - * Phase 9k: Media-Upload via MinIO. Bilder + Audio werden vor den - * Karten in den Cards-Bucket geladen, der Sanitize-Pfad ersetzt - * Anki-Filenames durch echte Media-URLs (`/api/v1/media/`). + * Wordeck-Rebrand (2026-05-17): Media-Refs (Bilder + Audio) werden + * beim Import komplett gedroppt. Wordeck ist text-only — der + * Sanitize-Pfad strippt `` und `[sound:…]` ersatzlos. * * Phase 9j Re-Import-Dedupe: content_hash-Set wird vor dem Loop * geladen, Duplikate werden gezählt und übersprungen. */ -import JSZip from 'jszip'; - import { cardContentHash } from '@cards/domain'; import { createDeck } from '$lib/api/decks.ts'; import { createCard, listCardHashes } from '$lib/api/cards.ts'; -import { uploadMedia } from '$lib/api/media.ts'; import { sanitizeAnkiHtml, type ParsedAnki } from './parse.ts'; export interface ImportResult { decksCreated: number; cardsCreated: number; cardsSkippedDuplicate: number; - mediaUploaded: number; - mediaFailed: number; failed: number; failures: string[]; } export interface ImportProgress { - stage: 'media' | 'decks' | 'cards' | 'done'; + stage: 'decks' | 'cards' | 'done'; current: number; total: number; } -const MEDIA_CONCURRENCY = 4; -const IMG_RE = /]*\bsrc=["']([^"']+)["']/gi; -const SOUND_RE = /\[sound:([^\]]+)\]/g; - -function collectMediaRefs(parsed: ParsedAnki): Set { - const refs = new Set(); - for (const card of parsed.cards) { - for (const value of Object.values(card.fields)) { - let m: RegExpExecArray | null; - IMG_RE.lastIndex = 0; - while ((m = IMG_RE.exec(value))) refs.add(m[1]); - SOUND_RE.lastIndex = 0; - while ((m = SOUND_RE.exec(value))) refs.add(m[1]); - } - } - return refs; -} - -function guessMime(filename: string): string { - const ext = filename.split('.').pop()?.toLowerCase() ?? ''; - const map: Record = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - webp: 'image/webp', - svg: 'image/svg+xml', - mp3: 'audio/mpeg', - ogg: 'audio/ogg', - oga: 'audio/ogg', - wav: 'audio/wav', - m4a: 'audio/mp4', - mp4: 'video/mp4', - webm: 'video/webm', - }; - return map[ext] ?? 'application/octet-stream'; -} - -async function uploadAllMedia( - parsed: ParsedAnki, - onProgress?: (current: number, total: number) => void -): Promise<{ urlByFilename: Map; uploaded: number; failed: number }> { - const referenced = [...collectMediaRefs(parsed)].filter((f) => parsed.mediaByFilename.has(f)); - const urlByFilename = new Map(); - let uploaded = 0; - let failed = 0; - let done = 0; - - if (referenced.length === 0) { - onProgress?.(0, 0); - return { urlByFilename, uploaded, failed }; - } - - let nextIdx = 0; - async function worker() { - while (true) { - const idx = nextIdx++; - if (idx >= referenced.length) return; - const filename = referenced[idx]; - const entry = parsed.mediaByFilename.get(filename); - if (!entry) { - failed++; - done++; - onProgress?.(done, referenced.length); - continue; - } - try { - const blob = await (entry as JSZip.JSZipObject).async('blob'); - const file = new File([blob], filename, { type: guessMime(filename) }); - const result = await uploadMedia(file); - urlByFilename.set(filename, result.url); - uploaded++; - } catch (e) { - console.warn(`[anki-import] media upload failed for ${filename}:`, e); - failed++; - } - done++; - onProgress?.(done, referenced.length); - } - } - - await Promise.all(Array.from({ length: MEDIA_CONCURRENCY }, () => worker())); - return { urlByFilename, uploaded, failed }; -} - export async function importParsedAnki( parsed: ParsedAnki, opts: { onProgress?: (p: ImportProgress) => void } = {} @@ -131,8 +41,6 @@ export async function importParsedAnki( decksCreated: 0, cardsCreated: 0, cardsSkippedDuplicate: 0, - mediaUploaded: 0, - mediaFailed: 0, failed: 0, failures: [], }; @@ -146,17 +54,7 @@ export async function importParsedAnki( // Dedupe bleibt aus (älterer Server o.ä.). } - // 1) Media — vor den Karten uploaden, damit der Sanitize-Pfad echte - // URLs einsetzen kann. Files, die nicht im Anki-Manifest stehen, - // werden gedroppt; Upload-Fehler werden gezählt + im Card-Field - // gedroppt (statt 404-URL). - const { urlByFilename, uploaded, failed } = await uploadAllMedia(parsed, (current, total) => { - opts.onProgress?.({ stage: 'media', current, total }); - }); - result.mediaUploaded = uploaded; - result.mediaFailed = failed; - - // 2) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen. + // 1) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen. const ankiIdToDeckId = new Map(); let deckIdx = 0; for (const ankiDeck of parsed.decks) { @@ -186,14 +84,14 @@ export async function importParsedAnki( } }; - // 3) Cards — sanitize mit URL-Map, content_hash-Dedupe, Insert. + // 2) Cards — sanitize strippt img + sound, content_hash-Dedupe, Insert. for (let i = 0; i < parsed.cards.length; i++) { opts.onProgress?.({ stage: 'cards', current: i, total: parsed.cards.length }); const card = parsed.cards[i]; const cleanFields: Record = {}; for (const [key, value] of Object.entries(card.fields)) { - cleanFields[key] = sanitizeAnkiHtml(value, urlByFilename); + cleanFields[key] = sanitizeAnkiHtml(value); } const hash = await cardContentHash({ type: card.type, fields: cleanFields }); diff --git a/apps/web/src/lib/anki/parse.ts b/apps/web/src/lib/anki/parse.ts index 583f2bf..0227846 100644 --- a/apps/web/src/lib/anki/parse.ts +++ b/apps/web/src/lib/anki/parse.ts @@ -218,36 +218,21 @@ function mapNoteToCard( * wird gedroppt — das passiert z.B. wenn der Media-Upload für diese * Datei fehlschlägt. * - * `` → Markdown `![alt](url)`. `[sound:foo.mp3]` → HTML - * `