feat(ai): chat / extract-invoices / usage / budget routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
124
src/routes/admin/ai.ts
Normal file
124
src/routes/admin/ai.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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 } from "../../schemas/ai.schema";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { config } from "../../config/env";
|
||||
import {
|
||||
isConfigured,
|
||||
assertBudgetAvailable,
|
||||
getMonthSpendUsd,
|
||||
getBudgetUsd,
|
||||
setBudgetUsd,
|
||||
chat,
|
||||
extractInvoice,
|
||||
type ExtractedInvoice,
|
||||
} from "../../services/ai.service";
|
||||
|
||||
export default async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||
await app.register(multipart, {
|
||||
limits: { fileSize: config.nas.maxUploadSize },
|
||||
});
|
||||
|
||||
// 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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user