feat(ai): chat / extract-invoices / usage / budget routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config as appConfig } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import aiRoutes from "../routes/admin/ai";
|
||||
import {
|
||||
computeCostUsd,
|
||||
recordUsage,
|
||||
@@ -91,3 +98,118 @@ describe("ai.service cost + budget", () => {
|
||||
expect(typeof isConfigured()).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP /api/admin/ai", () => {
|
||||
let app: Awaited<ReturnType<typeof buildAiApp>>;
|
||||
let adminToken: string;
|
||||
let noPermToken: string;
|
||||
let noPermRoleId: number;
|
||||
let noPermUserId: number;
|
||||
let savedKey: string;
|
||||
|
||||
async function buildAiApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(cookie);
|
||||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
a.addHook("onRequest", securityHeaders);
|
||||
await a.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||||
return a;
|
||||
}
|
||||
function token(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
appConfig.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
savedKey = appConfig.anthropic.apiKey;
|
||||
app = await buildAiApp();
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("admin not found");
|
||||
adminToken = token({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
const stamp = Date.now();
|
||||
const role = await prisma.roles.create({
|
||||
data: { name: `noperm_ai_${stamp}`, display_name: "No Perm AI" },
|
||||
});
|
||||
noPermRoleId = role.id;
|
||||
const u = await prisma.users.create({
|
||||
data: {
|
||||
username: `noperm_ai_${stamp}`,
|
||||
first_name: "No",
|
||||
last_name: "Perm",
|
||||
email: `noperm_ai_${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
role_id: role.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
noPermUserId = u.id;
|
||||
noPermToken = token({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
roleName: role.name,
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
||||
if (noPermUserId)
|
||||
await prisma.users
|
||||
.deleteMany({ where: { id: noPermUserId } })
|
||||
.catch(() => {});
|
||||
if (noPermRoleId)
|
||||
await prisma.roles
|
||||
.deleteMany({ where: { id: noPermRoleId } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
it("GET /usage requires ai.use", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/ai/usage",
|
||||
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("GET /usage returns spend + budget for an admin", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/ai/usage",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(typeof body.data.budget_usd).toBe("number");
|
||||
expect(typeof body.data.month_spend_usd).toBe("number");
|
||||
});
|
||||
|
||||
it("POST /chat returns 503 when AI is not configured", async () => {
|
||||
(appConfig.anthropic as { apiKey: string }).apiKey = "";
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/ai/chat",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||||
});
|
||||
expect(res.statusCode).toBe(503);
|
||||
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
20
src/schemas/ai.schema.ts
Normal file
20
src/schemas/ai.schema.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AiChatSchema = z.object({
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string().min(1).max(8000),
|
||||
}),
|
||||
)
|
||||
.min(1, "Zpráva je povinná")
|
||||
.max(100, "Příliš mnoho zpráv"),
|
||||
});
|
||||
|
||||
export const AiBudgetSchema = z.object({
|
||||
budget_usd: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((n) => Number.isFinite(n) && n >= 0, "Neplatný rozpočet"),
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import ordersPdfRoutes from "./routes/admin/orders-pdf";
|
||||
import projectFilesRoutes from "./routes/admin/project-files";
|
||||
import warehouseRoutes from "./routes/admin/warehouse";
|
||||
import planRoutes from "./routes/admin/plan";
|
||||
import aiRoutes from "./routes/admin/ai";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -152,6 +153,7 @@ async function start() {
|
||||
});
|
||||
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||
await app.register(planRoutes, { prefix: "/api/admin/plan" });
|
||||
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||||
|
||||
// --- Frontend: Vite dev middleware (dev only) ---
|
||||
if (!config.isProduction) {
|
||||
|
||||
Reference in New Issue
Block a user