Files
app/src/routes/admin/ai.ts
BOHA 6664e3bc4d feat(odin): conversation-scoped service + routes (replace /history)
Remove single-thread getChatHistory/appendChatMessages/clearChatHistory and the
three /history routes; add listConversations, createConversation, getConversation-
Messages, appendConversationMessages, renameConversation, deleteConversation with
ownership checks and auto-title; wire six /conversations[/:id] routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:21:13 +02:00

231 lines
8.1 KiB
TypeScript

import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import multipart from "@fastify/multipart";
import { requirePermission } from "../../middleware/auth";
import { success, error, parseId } from "../../utils/response";
import { parseBody } from "../../schemas/common";
import {
AiChatSchema,
AiBudgetSchema,
AiHistoryAppendSchema,
AiCreateConversationSchema,
AiRenameConversationSchema,
} from "../../schemas/ai.schema";
import { logAudit } from "../../services/audit";
import { config } from "../../config/env";
import {
isConfigured,
assertBudgetAvailable,
getMonthSpendUsd,
getBudgetUsd,
setBudgetUsd,
chat,
extractInvoice,
listConversations,
createConversation,
getConversationMessages,
appendConversationMessages,
renameConversation,
deleteConversation,
type ExtractedInvoice,
} from "../../services/ai.service";
export default async function aiRoutes(app: FastifyInstance): Promise<void> {
await app.register(multipart, {
// Cap files per request: the budget re-check already bounds *spend* mid-batch,
// but this bounds the work (one vision call per file) of a single request.
limits: { fileSize: config.nas.maxUploadSize, files: 20 },
});
// GET /api/admin/ai/usage — current-month spend + budget
app.get(
"/usage",
{ preHandler: requirePermission("ai.use") },
async (_request: FastifyRequest, reply: FastifyReply) => {
const [month_spend_usd, budget_usd] = await Promise.all([
getMonthSpendUsd(),
getBudgetUsd(),
]);
return success(reply, {
configured: isConfigured(),
month_spend_usd: Math.round(month_spend_usd * 1_000_000) / 1_000_000,
budget_usd,
remaining_usd: Math.max(0, budget_usd - month_spend_usd),
});
},
);
// PUT /api/admin/ai/budget — set the monthly budget (admins have ai.use)
app.put(
"/budget",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const body = parseBody(AiBudgetSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const { budget_usd } = body.data;
await setBudgetUsd(budget_usd);
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "company_settings",
description: `Změněn měsíční rozpočet AI na $${budget_usd}`,
newValues: { ai_monthly_budget_usd: budget_usd },
});
return success(reply, { budget_usd }, 200, "Rozpočet uložen");
},
);
// POST /api/admin/ai/chat
app.post(
"/chat",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
if (!isConfigured()) return error(reply, "AI není nakonfigurováno", 503);
const body = parseBody(AiChatSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const budgetErr = await assertBudgetAvailable();
if (budgetErr) return error(reply, budgetErr.error, budgetErr.status);
const { reply: text } = await chat(
body.data.messages,
request.authData!.userId,
);
const [budgetAfter, spendAfter] = await Promise.all([
getBudgetUsd(),
getMonthSpendUsd(),
]);
const remaining_usd = Math.max(0, budgetAfter - spendAfter);
return success(reply, { reply: text, remaining_usd });
},
);
// POST /api/admin/ai/extract-invoices — multipart PDF files
app.post(
"/extract-invoices",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
if (!isConfigured()) return error(reply, "AI není nakonfigurováno", 503);
const budgetErr = await assertBudgetAvailable();
if (budgetErr) return error(reply, budgetErr.error, budgetErr.status);
const parts = request.parts();
const results: Array<{
file_name: string;
fields?: ExtractedInvoice;
error?: string;
}> = [];
for await (const part of parts) {
if (part.type !== "file") continue;
const buf = await part.toBuffer();
try {
const fields = await extractInvoice(buf, request.authData!.userId);
results.push({ file_name: part.filename || "faktura.pdf", fields });
} catch (e) {
request.log.error(e, "extractInvoice failed");
results.push({
file_name: part.filename || "faktura.pdf",
error: "Nepodařilo se přečíst fakturu",
});
}
// Re-check the budget between files so one batch can't blow far past it.
const over = await assertBudgetAvailable();
if (over) break;
}
if (results.length === 0)
return error(reply, "Nebyl nahrán žádný soubor", 400);
return success(reply, { invoices: results });
},
);
// GET /conversations — this user's conversations (stable order)
app.get(
"/conversations",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const conversations = await listConversations(request.authData!.userId);
return success(reply, { conversations });
},
);
// POST /conversations — create
app.post(
"/conversations",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const body = parseBody(AiCreateConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const conversation = await createConversation(
request.authData!.userId,
body.data.title,
);
return success(reply, { conversation });
},
);
// GET /conversations/:id/messages
app.get(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await getConversationMessages(
request.authData!.userId,
id,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { messages: result.data });
},
);
// POST /conversations/:id/messages — append turns
app.post(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(AiHistoryAppendSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await appendConversationMessages(
request.authData!.userId,
id,
body.data.messages,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
},
);
// PATCH /conversations/:id — rename
app.patch(
"/conversations/:id",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(AiRenameConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await renameConversation(
request.authData!.userId,
id,
body.data.title,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { conversation: result.data });
},
);
// DELETE /conversations/:id
app.delete(
"/conversations/:id",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await deleteConversation(request.authData!.userId, id);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
},
);
}