Compare commits

..

6 Commits

Author SHA1 Message Date
BOHA
164ce54adf chore(release): v2.4.28 - Odin expense totals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:51 +02:00
BOHA
6877919133 feat(odin): received-invoices expense totals with CZK conversion
get_document_totals gains type=received_invoices: whole-set gross sums per
currency + total_czk via the same toCzk logic as the stats route (unknown
currencies skipped + reported), month/year via the literal columns (bare
year supported), unpaid/paid filter, period echo. invoices.view joined the
coarse gate (the per-type re-check keeps other families locked); the tool
description routes 'kolik jsme utratili' questions here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:51 +02:00
BOHA
5ec70599aa chore(release): v2.4.27 - rich-text blank lines
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:35:46 +02:00
BOHA
7e4469b29a fix(richtext): blank lines survive into PDFs and read-only previews
Quill 2's semantic HTML saves a blank line as a truly empty <p></p> (no
<br> placeholder) — verified in the DB (invoice_sections hold <p></p>,
zero <p><br>) — which collapses to nothing outside the editor.
cleanQuillHtml (PDF path) and the new restoreQuillBlankLines (order/
invoice read-only previews) restore the placeholder at render time, so
existing stored content is covered retroactively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:35:46 +02:00
BOHA
08af4cd0ae chore(release): v2.4.26 - PDF font unification + rich-text colors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:27:47 +02:00
BOHA
eb279a87a9 fix(pdf): Segoe UI everywhere + rich-text colors survive sanitization
- section/Quill overrides, footers and the shared header now lead with
  'Segoe UI' (installed on prod) instead of Tahoma (not installed — prod
  silently rendered sections and footers in Noto Sans, mismatching the
  Segoe UI body; dev previews showed real Tahoma and never matched prod)
- cleanQuillHtml no longer deletes ALL style attributes: color and
  background-color survive with strictly validated literal values (hex,
  rgb/rgba, keyword) so editor font colors finally reach the PDF; url(),
  expression() and every other declaration are still stripped — new test
  file pins both the kept colors and the attack vectors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:27:47 +02:00
13 changed files with 223 additions and 28 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.4.25",
"version": "2.4.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.4.25",
"version": "2.4.28",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.4.25",
"version": "2.4.28",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -765,6 +765,34 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
);
});
it("get_document_totals: received_invoices sums gross + converts to CZK", async () => {
// invoices.view alone unlocks the received-invoices branch.
const res = await executeTool(
"get_document_totals",
{ type: "received_invoices", month: 6, year: 2098 },
VIEWER_INVOICES,
);
expect(res.ok).toBe(true);
const r = res.result as {
period: string;
invoice_count: number;
totals: { currency: string; amount: number }[];
total_czk: number;
};
expect(r.period).toBe("6/2098");
expect(r.invoice_count).toBe(1);
expect(r.totals).toEqual([{ currency: "CZK", amount: 1210 }]);
expect(r.total_czk).toBe(1210); // CZK rows convert 1:1, no rate fetch
// orders.view alone cannot read expense totals.
const denied = await executeTool(
"get_document_totals",
{ type: "received_invoices" },
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
);
expect(denied.ok).toBe(false);
});
it("get_top_customers aggregates per customer across the whole table", async () => {
const res = await executeTool(
"get_top_customers",

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { cleanQuillHtml } from "../utils/pdf-shared";
describe("cleanQuillHtml — inline-style whitelist", () => {
it("keeps Quill text and background colors", () => {
const html =
'<p><span style="color: rgb(230, 0, 0);">červeně</span> ' +
'<span style="background-color: #ffff00;">zvýrazněně</span> ' +
'<span style="color: #06c;">modře</span></p>';
const out = cleanQuillHtml(html);
expect(out).toContain('style="color: rgb(230, 0, 0)"');
expect(out).toContain('style="background-color: #ffff00"');
expect(out).toContain('style="color: #06c"');
});
it("drops every non-color style declaration", () => {
const out = cleanQuillHtml(
'<span style="position: fixed; color: red; font-size: 99px;">x</span>',
);
expect(out).toBe('<span style="color: red">x</span>');
});
it("drops unsafe color values (url, expression, quotes)", () => {
expect(
cleanQuillHtml('<span style="color: url(javascript:alert(1))">x</span>'),
).toBe("<span>x</span>");
expect(
cleanQuillHtml('<span style="color: expression(alert(1))">x</span>'),
).toBe("<span>x</span>");
expect(cleanQuillHtml("<span style='color: \"red\"'>x</span>")).toBe(
"<span>x</span>",
);
});
it("restores Quill 2's empty <p></p> blank lines to <p><br></p>", () => {
// Quill 2's semantic HTML drops the <br> placeholder from blank lines.
expect(cleanQuillHtml("<p>A</p><p></p><p>B</p>")).toBe(
"<p>A</p><p><br></p><p>B</p>",
);
// Attribute-carrying and whitespace-only empties too.
expect(cleanQuillHtml('<p class="ql-align-center"> </p>')).toBe(
'<p class="ql-align-center"><br></p>',
);
});
it("keeps stripping scripts, event handlers and js: URLs", () => {
const out = cleanQuillHtml(
'<p onclick="alert(1)"><script>alert(1)</script>' +
'<a href="javascript:alert(1)">x</a></p>',
);
expect(out).not.toContain("script");
expect(out).not.toContain("onclick");
expect(out).not.toContain("javascript:");
});
});

View File

@@ -58,6 +58,7 @@ import {
formatCurrency,
formatDate,
numberOr,
restoreQuillBlankLines,
todayLocalStr,
} from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
@@ -1670,7 +1671,9 @@ export default function InvoiceDetail() {
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
<RichTextView
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(s.content || ""),
__html: DOMPurify.sanitize(
restoreQuillBlankLines(s.content),
),
}}
/>
)}
@@ -1690,7 +1693,9 @@ export default function InvoiceDetail() {
invoice.internal_notes !== "<p><br></p>" ? (
<RichTextView
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(invoice.internal_notes),
__html: DOMPurify.sanitize(
restoreQuillBlankLines(invoice.internal_notes),
),
}}
/>
) : (

View File

@@ -18,7 +18,11 @@ import Typography from "@mui/material/Typography";
import OrderConfirmationModal from "../components/OrderConfirmationModal";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
formatCurrency,
formatDate,
restoreQuillBlankLines,
} from "../utils/formatters";
import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
import {
Button,
@@ -713,7 +717,9 @@ export default function OrderDetail() {
<RichTextView
sx={{ p: 2 }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(section.content),
__html: DOMPurify.sanitize(
restoreQuillBlankLines(section.content),
),
}}
/>
)}

View File

@@ -76,3 +76,15 @@ export function czechPlural(
if (n >= 2 && n <= 4) return few;
return many;
}
/**
* Quill 2's semantic HTML saves blank lines as truly empty <p></p>, which
* collapse to zero height outside the editor — restore the <br> placeholder
* before rendering stored rich text read-only. (The PDF path does the same
* in cleanQuillHtml.)
*/
export function restoreQuillBlankLines(
html: string | null | undefined,
): string {
return (html || "").replace(/<p([^>]*)>\s*<\/p>/gi, "<p$1><br></p>");
}

View File

@@ -253,7 +253,7 @@ export function buildInvoicePdfFooter(
const t = translations[lang] || translations.cs;
const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of";
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div>
@@ -898,7 +898,7 @@ export default async function invoicesPdfRoutes(
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important;
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
}
.section-content { font-size: 14px; }
.section-content h1 { font-size: 20px; }
@@ -910,7 +910,7 @@ export default async function invoicesPdfRoutes(
.section-content li { margin-bottom: 0.2em; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }

View File

@@ -267,7 +267,7 @@ export function buildIssuedOrderPdfFooter(
const t = translations[lang];
const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of";
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div>
@@ -655,7 +655,7 @@ export function renderIssuedOrderHtml(
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important;
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
}
.section-content { font-size: 14px; }
.section-content h1 { font-size: 20px; }
@@ -667,7 +667,7 @@ export function renderIssuedOrderHtml(
.section-content li { margin-bottom: 0.2em; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }

View File

@@ -293,7 +293,7 @@ export function renderOfferHtml(
img, table, pre, code { max-width: 100%; }
/* ---- Quill font classes v PDF vynuceno Tahoma ---- */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
/* ---- Quill alignment ---- */
.ql-align-center { text-align: center; }
@@ -502,7 +502,7 @@ ${indentCSS}
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important;
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
}
.section-content { font-size: 14px; }
.section-content h1 { font-size: 20px; }

View File

@@ -570,7 +570,7 @@ export function renderOrderConfirmationHtml(
.invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content,
.invoice-notes-content * {
font-family: Tahoma, sans-serif !important;
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
}
.invoice-notes-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; }
@@ -579,7 +579,7 @@ export function renderOrderConfirmationHtml(
.invoice-notes-content h4 { font-size: 15px; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }

View File

@@ -16,6 +16,7 @@ import {
getWorkfund,
getProjectReport,
} from "./attendance.service";
import { toCzk } from "./exchange-rates";
import { resolveGrid, listPlanUsers } from "./plan.service";
/**
@@ -1688,35 +1689,38 @@ const TOOLS: ToolSpec[] = [
},
{
// Coarse gate any-of; the handler re-checks the exact permission per
// document type (offers vs orders families).
permission: ["offers.view", "orders.view"],
// document type (offers vs orders vs invoices families).
permission: ["offers.view", "orders.view", "invoices.view"],
definition: {
name: "get_document_totals",
description:
"Součty hodnot dokumentů za CELÝ filtr (ne jen prvních 20 z listu!) po měnách, BEZ DPH. Typy: offers (nabídky), orders (objednávky přijaté), issued_orders (objednávky vydané). VŽDY použij tento nástroj místo sčítání řádků z list_* nástrojů, když je výsledků víc než 20.",
"Součty hodnot dokumentů za CELÝ filtr (ne jen prvních 20 z listu!) po měnách. Typy: offers (nabídky, bez DPH), orders (objednávky přijaté, bez DPH), issued_orders (objednávky vydané, bez DPH), received_invoices (přijaté faktury = VÝDAJE, včetně DPH, s přepočtem do CZK). VŽDY použij tento nástroj místo sčítání řádků z list_* nástrojů. Na dotazy 'kolik jsme utratili' použij received_invoices a/nebo issued_orders.",
input_schema: {
type: "object",
properties: {
type: {
type: "string",
enum: ["offers", "orders", "issued_orders"],
description: "Typ dokumentů",
enum: ["offers", "orders", "issued_orders", "received_invoices"],
description:
"Typ dokumentů (received_invoices = přijaté faktury / výdaje, VČETNĚ DPH + přepočet do CZK)",
},
status: {
type: "string",
description: "Filtr stavu (vynech pro všechny)",
description:
"Filtr stavu (vynech pro všechny; received_invoices: unpaid/paid)",
},
search: {
type: "string",
description: "Hledání jako v list_* nástroji",
description:
"Hledání jako v list_* nástroji (ne received_invoices)",
},
month: {
type: "number",
description: "Měsíc 1-12 (jen orders/issued_orders)",
description: "Měsíc 1-12 (ne offers)",
},
year: {
type: "number",
description: "Rok (jen orders/issued_orders)",
description: "Rok (ne offers)",
},
},
required: ["type"],
@@ -1766,6 +1770,59 @@ const TOOLS: ToolSpec[] = [
const { totals } = await getOrderTotals(filters);
return { type, period, totals, note: "Součty bez DPH, po měnách." };
}
if (type === "received_invoices") {
if (!ctxCan(ctx, "invoices.view")) {
return { error: DENIED("přijaté faktury") };
}
// month/year are literal columns on received_invoices; gross sums
// per currency + CZK conversion mirror GET /received-invoices/stats.
const where: Record<string, unknown> = {};
if (month && year) {
where.month = month;
where.year = year;
} else if (year) {
where.year = year;
}
const status = str(input.status);
if (status === "unpaid" || status === "paid") where.status = status;
const rows = await prisma.received_invoices.findMany({
where,
select: { amount: true, currency: true },
});
const byCurrency: Record<string, number> = {};
for (const r of rows) {
const cur = r.currency || "CZK";
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(r.amount) || 0);
}
const totals = Object.entries(byCurrency).map(([currency, amount]) => ({
currency,
amount: round2(amount),
}));
// CZK conversion mirrors the stats route: a row with an unknown
// currency is skipped (logged), never fails the whole sum.
let totalCzk = 0;
let skipped = 0;
for (const r of rows) {
try {
totalCzk += await toCzk(Number(r.amount) || 0, r.currency);
} catch (e) {
skipped += 1;
console.error(
`[ai-tools] toCzk failed for currency "${r.currency}"`,
e,
);
}
}
return {
type,
period: month && year ? `${month}/${year}` : year ? `${year}` : "vše",
invoice_count: rows.length,
totals,
total_czk: round2(totalCzk),
...(skipped ? { czk_conversion_skipped_rows: skipped } : {}),
note: "Částky jsou VČETNĚ DPH (gross); total_czk je přepočet aktuálním kurzem.",
};
}
if (type === "issued_orders") {
if (!ctxCan(ctx, "orders.view")) {
return { error: DENIED("objednávky vydané") };

View File

@@ -94,7 +94,7 @@ export function buildPdfHeaderTemplate(
logoDataUri: string,
heading: string,
): string {
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
return `<div style="width:100%; font-family:'Segoe UI', Tahoma, Arial, sans-serif; padding:0 12mm; box-sizing:border-box;">
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
@@ -108,6 +108,13 @@ export function buildPdfHeaderTemplate(
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
* (regex pass = defense-in-depth, not the primary sanitizer).
*/
/**
* Literal CSS color values only: #hex, rgb()/rgba() with numeric components,
* or a bare keyword. No quotes, parentheses beyond rgb(a), url(), variables.
*/
const SAFE_CSS_COLOR =
/^(#[0-9a-f]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)|[a-z]{3,20})$/i;
export function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
let s = html;
@@ -134,7 +141,32 @@ export function cleanQuillHtml(html: string | null | undefined): string {
);
// Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
// Quill 2's semantic HTML saves blank lines as truly empty <p></p>, which
// collapse to zero height in the PDF — restore the <br> placeholder so an
// editor blank line stays a blank line.
s = s.replace(/<p([^>]*)>\s*<\/p>/gi, "<p$1><br></p>");
// Inline styles: Quill writes text/background colors as style attributes.
// Keep ONLY color/background-color with literal color values — everything
// else (url(), expression(), positioning…) must never reach Puppeteer.
s = s.replace(
/\s+style\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi,
(_m, dq, sq, bare) => {
const kept = String(dq ?? sq ?? bare ?? "")
.split(";")
.map((decl) => {
const i = decl.indexOf(":");
if (i < 0) return null;
const prop = decl.slice(0, i).trim().toLowerCase();
const val = decl.slice(i + 1).trim();
return (prop === "color" || prop === "background-color") &&
SAFE_CSS_COLOR.test(val)
? `${prop}: ${val}`
: null;
})
.filter((d): d is string => d !== null);
return kept.length ? ` style="${kept.join("; ")}"` : "";
},
);
// Merge adjacent spans with same attributes
let prev = "";
while (prev !== s) {