diff --git a/src/__tests__/ai.test.ts b/src/__tests__/ai.test.ts index 8208aa2..da0a7a0 100644 --- a/src/__tests__/ai.test.ts +++ b/src/__tests__/ai.test.ts @@ -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>; + 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; + }); +}); diff --git a/src/routes/admin/ai.ts b/src/routes/admin/ai.ts new file mode 100644 index 0000000..261323b --- /dev/null +++ b/src/routes/admin/ai.ts @@ -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 { + 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 }); + }, + ); +} diff --git a/src/schemas/ai.schema.ts b/src/schemas/ai.schema.ts new file mode 100644 index 0000000..2616057 --- /dev/null +++ b/src/schemas/ai.schema.ts @@ -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"), +}); diff --git a/src/server.ts b/src/server.ts index 22fa0fa..09c9ec3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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) {