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 prisma from "../config/database";
|
||||||
|
import { config as appConfig } from "../config/env";
|
||||||
|
import { securityHeaders } from "../middleware/security";
|
||||||
|
import aiRoutes from "../routes/admin/ai";
|
||||||
import {
|
import {
|
||||||
computeCostUsd,
|
computeCostUsd,
|
||||||
recordUsage,
|
recordUsage,
|
||||||
@@ -91,3 +98,118 @@ describe("ai.service cost + budget", () => {
|
|||||||
expect(typeof isConfigured()).toBe("boolean");
|
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 projectFilesRoutes from "./routes/admin/project-files";
|
||||||
import warehouseRoutes from "./routes/admin/warehouse";
|
import warehouseRoutes from "./routes/admin/warehouse";
|
||||||
import planRoutes from "./routes/admin/plan";
|
import planRoutes from "./routes/admin/plan";
|
||||||
|
import aiRoutes from "./routes/admin/ai";
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -152,6 +153,7 @@ async function start() {
|
|||||||
});
|
});
|
||||||
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||||
await app.register(planRoutes, { prefix: "/api/admin/plan" });
|
await app.register(planRoutes, { prefix: "/api/admin/plan" });
|
||||||
|
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||||||
|
|
||||||
// --- Frontend: Vite dev middleware (dev only) ---
|
// --- Frontend: Vite dev middleware (dev only) ---
|
||||||
if (!config.isProduction) {
|
if (!config.isProduction) {
|
||||||
|
|||||||
Reference in New Issue
Block a user