import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import multipart from "@fastify/multipart"; import { requirePermission, requireAnyPermission } 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, agenticChat, extractInvoice, listConversations, createConversation, getConversationMessages, appendConversationMessages, renameConversation, deleteConversation, type ExtractedInvoice, } from "../../services/ai.service"; export default async function aiRoutes(app: FastifyInstance): Promise { 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. A COMPANY setting // (budget_usd=0 is a company-wide AI DoS; a huge value removes the cost // cap), so it is gated like company_settings writes, NOT by ai.use. app.put( "/budget", { preHandler: requireAnyPermission("settings.company", "settings.system"), }, 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 — agentic turn with read-only tools (Phase 2a). // The tools execute AS this user: the authData context (permissions + // admin bypass) is what each tool handler checks — see ai-tools.ts. 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 auth = request.authData!; const { reply: text, toolTrace } = await agenticChat(body.data.messages, { userId: auth.userId, // AuthData.roleName is nullable; a null role simply never matches the // admin bypass and holds only its explicit permissions. roleName: auth.roleName ?? "", permissions: auth.permissions, }); const [budgetAfter, spendAfter] = await Promise.all([ getBudgetUsd(), getMonthSpendUsd(), ]); const remaining_usd = Math.max(0, budgetAfter - spendAfter); return success(reply, { reply: text, tool_trace: toolTrace, 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; }> = []; // True when the loop stops early because the budget was hit mid-batch — // surfaced to the client so it knows trailing files were NOT processed. let truncated = false; 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) { truncated = true; break; } } if (results.length === 0) return error(reply, "Nebyl nahrán žádný soubor", 400); return success(reply, { invoices: results, truncated }); }, ); // 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); }, ); }