import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import multipart from "@fastify/multipart"; import { requirePermission } from "../../middleware/auth"; import { success, error } from "../../utils/response"; import { parseBody } from "../../schemas/common"; import { AiChatSchema, AiBudgetSchema, AiHistoryAppendSchema, } from "../../schemas/ai.schema"; import { logAudit } from "../../services/audit"; import { config } from "../../config/env"; import { isConfigured, assertBudgetAvailable, getMonthSpendUsd, getBudgetUsd, setBudgetUsd, chat, extractInvoice, getChatHistory, appendChatMessages, clearChatHistory, 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 (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 /api/admin/ai/history — this user's stored chat thread app.get( "/history", { preHandler: requirePermission("ai.use") }, async (request: FastifyRequest, reply: FastifyReply) => { const messages = await getChatHistory(request.authData!.userId); return success(reply, { messages }); }, ); // POST /api/admin/ai/history — append turns to this user's thread app.post( "/history", { preHandler: requirePermission("ai.use") }, async (request: FastifyRequest, reply: FastifyReply) => { const body = parseBody(AiHistoryAppendSchema, request.body); if ("error" in body) return error(reply, body.error, 400); await appendChatMessages(request.authData!.userId, body.data.messages); return success(reply, { ok: true }); }, ); // DELETE /api/admin/ai/history — clear this user's thread app.delete( "/history", { preHandler: requirePermission("ai.use") }, async (request: FastifyRequest, reply: FastifyReply) => { await clearChatHistory(request.authData!.userId); return success(reply, { ok: true }); }, ); }