import { eq } from 'drizzle-orm'; import { Hono } from 'hono'; import { CardsCreateInputSchema, CardsSearchInputSchema, cardContentHash, subIndexCount, subIndexCountForCloze, } from '@wordeck/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.WORDECK_PUBLIC_URL ?? 'https://wordeck.com'; const APP_VERSION = process.env.WORDECK_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: 'wordeck', app_version: APP_VERSION, base_url: APP_BASE_URL, }); } default: return c.json({ error: 'unknown_tool', name }, 404); } }); return r; }