wordeck/apps/api/src/routes/tools.ts
Till JS e3c84a9249
Some checks are pending
CI / validate (push) Waiting to run
feat(text-only): Wordeck-Cutoff für Image-Occlusion + Audio + MinIO
Ω-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>
2026-05-17 21:23:30 +02:00

147 lines
4.3 KiB
TypeScript

import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import {
CardsCreateInputSchema,
CardsSearchInputSchema,
cardContentHash,
subIndexCount,
subIndexCountForCloze,
} from '@cards/domain';
import { makeInitialReviewRows } from '../lib/reviews.ts';
import { getDb, type CardsDb } from '../db/connection.ts';
import { cards, decks, reviews } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { ulid } from '../lib/ulid.ts';
import { searchUserCards } from '../lib/search.ts';
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 };
/**
* Tool-Invoke-Endpoint für mana-mcp / Persona-Runner / Claude.
* Dispatch nach `:name`. Auth: User-JWT (X-User-Id-Header im Dev-Stub).
*
* Phase F-1: zusätzlich Service-Key-Pfad für mcp-getriggerte Calls
* mit user-on-behalf-of-Token.
*/
export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
r.post('/:name', async (c) => {
const userId = c.get('userId');
const name = c.req.param('name');
const body = await c.req.json().catch(() => null);
if (body == null) return c.json({ error: 'invalid_json' }, 400);
switch (name) {
case 'cards.create': {
const parsed = CardsCreateInputSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
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);
// Text-abhängige Sub-Index-Counts identisch zum REST-Pfad
// (cards.ts POST). Cloze ohne Cluster wird 422.
let count: number;
if (parsed.data.type === 'cloze') {
count = subIndexCountForCloze(parsed.data.fields.text ?? '');
if (count === 0) {
return c.json(
{ error: 'invalid_input', issues: ['cloze.text contains no {{cN::…}} clusters'] },
422
);
}
} else {
count = subIndexCount(parsed.data.type);
}
const cardId = ulid();
const now = new Date();
const contentHash = await cardContentHash({
type: parsed.data.type,
fields: parsed.data.fields,
});
const [row] = await dbOf().transaction(async (tx) => {
const [card] = await tx
.insert(cards)
.values({
id: cardId,
deckId: parsed.data.deck_id,
userId,
type: parsed.data.type,
fields: parsed.data.fields,
contentHash,
createdAt: now,
updatedAt: now,
})
.returning();
const subIndices = Array.from({ length: count }, (_, i) => i);
const initial = makeInitialReviewRows({ userId, cardId, subIndices, now });
if (initial.length > 0) await tx.insert(reviews).values(initial);
return [card];
});
return c.json({
id: row.id,
deck_id: row.deckId,
user_id: row.userId,
type: row.type,
fields: row.fields,
content_hash: row.contentHash,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
});
}
case 'cards.search': {
const parsed = CardsSearchInputSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const max = parsed.data.max_results ?? 30;
const { hits, tookMs } = await searchUserCards(dbOf(), userId, parsed.data.query, max);
return c.json({
query: parsed.data.query,
results: hits.map((h) => ({
id: h.id,
type: 'card' as const,
title: h.title,
snippet: h.snippet,
link: h.link,
score: h.score,
})),
total: hits.length,
took_ms: tookMs,
app: 'cards',
app_version: APP_VERSION,
base_url: APP_BASE_URL,
});
}
default:
return c.json({ error: 'unknown_tool', name }, 404);
}
});
return r;
}