Server-side, per-user chat history (the thread now survives reload): - new ai_chat_messages table + migration; getChatHistory/appendChatMessages/ clearChatHistory; GET/POST/DELETE /api/admin/ai/history (ai.use-guarded); service + HTTP tests (isolation, ordering, clear, perm, validation). - DashAssistant loads the thread on mount and persists each turn; "Vymazat" clears it. gcTime:0 so each mount re-reads the DB (no stale-cache resurrection); composer gated until history settles + seed flag flipped on submit (no seed/submit race clobbering a freshly-sent turn). UX + correctness fixes from review/feedback: - review cards moved OUT of the scrollable thread into a "Faktury k potvrzení" section so Uložit is always reachable; chat auto-scroll no longer fires on field edits. - chat bubble text colour set on the Typography (GlobalStyles pins `p` to text.secondary, which was overriding the inherited bubble colour → unreadable). - never clear input/staged PDFs on a failed submit (rollback + keep), so a budget error can't wipe staged invoices; roll back the optimistic user turn. - stable attachment ids (no crypto.randomUUID — undefined over plain HTTP). - editable Datum splatnosti + Popis fields; client-side extraction summary. Security: DashProfile clipboard copy now uses an execCommand fallback (works over plain HTTP) and only shows the success toast when the copy actually succeeded — previously it falsely claimed to copy 2FA codes / the TOTP secret. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
5.8 KiB
TypeScript
166 lines
5.8 KiB
TypeScript
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<void> {
|
|
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 });
|
|
},
|
|
);
|
|
}
|