wordeck/apps/api/src/routes/tools.ts
Till JS 372832d266
Some checks are pending
CI / validate (push) Waiting to run
refactor(big-bang): cards → wordeck im gesamten Code-Layer
Phase 2 des cards→wordeck Big-Bang-Rebrand:

- 4 package.json: @cards/* → @wordeck/*
- packages/cards-domain/ → packages/wordeck-domain/
- 41+12 Files: from '@cards/domain' → '@wordeck/domain'
- pgSchema('cards') → pgSchema('wordeck') (Drizzle-Schema)
- 17 Files: process.env.CARDS_* → process.env.WORDECK_*
- docker-compose Service-Names: cards-* → wordeck-*
- docker-compose Volume: /Volumes/ManaData/cards → wordeck
- env-vars in compose: CARDS_DB_PASSWORD/_API_VERSION/_DSGVO_SERVICE_KEY etc. → WORDECK_*
- Log-Prefixes + Error-Strings + manifest-id 'cards' → 'wordeck'
- CORS-Origin cardecky.mana.how → wordeck.com
- .env.production.example umbenannt + S3-Key entfernt (kein MinIO mehr)

Type-Check 0 Errors in api+domain+web, 51/51 Domain-Tests grün.

DB-Rename + Container/Volume-Rename auf mana-server folgen in nächstem
Commit nach Verzeichnis-Rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:39:42 +02:00

147 lines
4.3 KiB
TypeScript

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