feat(text-only): Wordeck-Cutoff für Image-Occlusion + Audio + MinIO
Some checks are pending
CI / validate (push) Waiting to run

Ω-1: Text-Only-Architektur ist scharfgestellt.

Code-Cleanup:
- 4 Components gelöscht: ImageOcclusionEditor, ImageOcclusionView,
  AudioFrontView, AudioUploadField
- 3 API-Module gelöscht: routes/media.ts, services/storage.ts,
  db/schema/media.ts (mediaRefs + mediaFiles), routes/decks-from-image.ts
- packages/cards-domain: image-occlusion.ts + Tests entfernt,
  CardTypeSchema reduziert auf basic/basic-reverse/cloze/typing/multiple-choice
- 3 Web-Routes (study/[deckId], cards/new, cards/[id]/edit) bereinigt:
  Image-Occlusion- und Audio-Front-Code-Pfade raus
- anki/import.ts text-only: kein Media-Upload mehr, img/sound werden
  ersatzlos gestrippt
- 21 weitere Files bereinigt: dto, health, me, dsgvo, tools, cards,
  decks, share-handlers, marketplace/decks, marketplace/fork,
  marketplace/pull-requests, AnkiImport.svelte

DB-Migrationen (noch nicht gerannt, idempotent):
- 0004_wordeck_text_only.sql: DELETE image-occlusion/audio (0 betroffene
  Rows), media_files-Tabelle DROP, media_refs-Spalte DROP, CHECK
  cards.type IN (basic, basic-reverse, cloze, type-in, multiple-choice)
- 0005_wordeck_license_rename.sql: Cardecky-Personal-Use-1.0 →
  Wordeck-Personal-Use-1.0, Cardecky-Pro-Only-1.0 → Wordeck-Pro-Only-1.0,
  Default + CHECK + Backfill

Infrastruktur:
- docker-compose.production.yml: cards-minio-Service raus, MinIO-Envs
  aus cards-api raus, CARDS_PUBLIC_URL + PUBLIC_CARDS_API_URL auf
  wordeck.com / api.wordeck.com
- App-Manifest schon vorher auf wordeck umgestellt

Type-Check grün (api, domain, web — alle 3 Sub-Pakete).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-17 21:23:30 +02:00
parent 1228eb4692
commit e3c84a9249
42 changed files with 149 additions and 1955 deletions

View file

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

View file

@ -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<string[]>().default([]),
contentHash: text('content_hash'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()

View file

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

View file

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

View file

@ -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>/<ulid>.<ext>` 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;

View file

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

View file

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

View file

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

View file

@ -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": "<kurzer Titel, max 80 Zeichen>",
"deck_description": "<eine Zeile Beschreibung, optional>",
"cards": [
{ "front": "<Frage oder Begriff>", "back": "<Antwort oder Erklärung>" },
...
]
}
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<typeof GeneratedDeckSchema>;
try {
const raw = await chatVisionJson<unknown>({
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;
}

View file

@ -70,7 +70,6 @@ export async function insertGeneratedDeck(
userId,
type: 'basic',
fields: cr.fields,
mediaRefs: [],
contentHash: cr.contentHash,
createdAt: now,
updatedAt: now,

View file

@ -185,7 +185,6 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
userId,
type: card.type,
fields: card.fields as Record<string, string>,
mediaRefs: card.mediaRefs,
contentHash: card.contentHash,
createdAt: now,
updatedAt: now,

View file

@ -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 `<userId>/<ulid>.<ext>`).
* 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,
},
});
});

View file

@ -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<ProbeResult> {
}
}
async function probeStorage(): Promise<ProbeResult> {
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);

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, string> = {
'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/<id>`), den
* Frontend + Anki-Importer in `<img src=...>` 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;
}

View file

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

View file

@ -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<void> {
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<void> {
await this.ensureBucket();
await this.client.putObject(this.bucket, key, Buffer.from(body), body.byteLength, {
'Content-Type': contentType,
});
}
async getObjectStream(key: string): Promise<NodeJS.ReadableStream> {
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<void> {
await this.ensureBucket();
await this.client.removeObject(this.bucket, key);
}
async removeObjectsByPrefix(prefix: string): Promise<number> {
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;
}

View file

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