Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Formát je inspirovaný Keep a Changelog.

### Opraveno

- CI fixture bootstrap nyní používá strict `classic-v2` master pod legacy testovacím klíčem `classic-v1`, takže historický PDF 1.4 master není omylem posuzován jako nový PDF/X-4 výstup; publikovaný náhled zároveň neprovádí synchronní změnu React state v efektu.
- Prepress inspekce ICC profilů nyní správně reportuje verzi podle ICC header encoding, takže oficiální ColorPoint profil je uváděn jako `2.4.0` místo `0.2.4`; profilové bytes, OutputIntent i SHA-256 se nemění.
- Připraven nový immutable `classic-v2` master pro ColorPoint/Fujifilm Revoria uncoated workflow s originálním CMYK OutputIntent profilem; historický `classic-v1` ani produkční default se automaticky nemění.
- Tiskový PRINT a STOCK export voucherů nyní zachovává dokumentový OutputIntent s ověřeným CMYK ICC profilem masteru, vytváří přesnou PDF/X-4 XMP identitu (`GTS_PDFXVersion=PDF/X-4`), vyžaduje PDF verzi minimálně 1.6 a sdílí neměnné master resources napříč vícestránkovou sérií; strict preflight nevyhovující tiskové mastery odmítne.
- Publikovaný náhled voucherové šablony nyní vykresluje textové placeholdery podle absolutní pozice uložené baseline; po publikaci se už nesesunou k horní hraně náhledu.
- QR kód v PDF má včetně bílého podkladu a ochranné zóny odsazení 0,5 mm od hran své oblasti, aby nepřekrýval ohraničení v tiskovém podkladu; editor zobrazuje stejný výsledek.
- Editor voucherových šablon nyní zobrazuje obsah pouze z náhledu vyrenderovaného stejným testovacím PDF, takže QR rámečky a polohy textů odpovídají exportu i u masterů s vlastními překryvy.
Expand Down
4 changes: 3 additions & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ Rollout musí držet následující pořadí; nový web ani worker nesmí mezi m
4. Spusť `npx prisma migrate deploy`.
5. Z adresáře nového release spusť povinný `npm run voucher:templates:bootstrap` se stejným env (`DATABASE_URL`, `MEDIA_STORAGE_ROOT`) a dependencies jako nový runtime. Bootstrap je idempotentní: vytvoří nebo načte publikovaný `classic-v1`, validuje master i preview, opraví neplatný preview, backfilluje legitimní historické vouchery na `VoucherTemplate.id` a ověří, že počet řádků s `templateId IS NULL` je nula.
6. Teprve po úspěšném bootstrapu atomicky aktivuj nový release, spusť web a worker a proveď health/smoke kontroly. Při selhání migrace nebo bootstrapu release neaktivuj.
7. U tiskárny kontroluj geometrii ColorPoint (trim 210 × 99 mm, bleed 3 mm). Výstup není deklarován jako PDF/X-4 VERIFIED bez externího validačního nástroje.
7. U tiskárny kontroluj geometrii ColorPoint (trim 210 × 99 mm, bleed 3 mm). Interní preflight ověřuje tiskový kontrakt a reportuje `STRUCTURALLY_VALIDATED`; bez externího validačního nástroje se výstup neoznačuje jako externě ověřený PDF/X-4.

Připravený `classic-v2` master s oficiálním profilem ColorPoint/Fujifilm Revoria uncoated je pouze kandidátní asset: bootstrap ani runtime automaticky nemění produkční default a nevytvářejí databázový záznam. Schválený rollout musí samostatně nahrát master do privátního storage, vytvořit a validovat publikovaný `VoucherTemplate` s klíčem `classic-v2`, provést PRINT/STOCK smoke test a teprve po schválení změnit `SiteSettings` default; historické vouchery `classic-v1` musí dál odkazovat na původní template.

- Před releasem s migrací `20260710110000_availability_slot_capacity_one` ověř `AvailabilitySlot` dotazem `WHERE "capacity" <> 1`. Migrace se při nálezu bezpečně zastaví; hodnoty neopravuj hromadně bez kontroly souběžných rezervací.

Expand Down
3 changes: 2 additions & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Pro centralizovaný přehled hlavních route handler kontraktů používej i [`d
- `VoucherTemplate` je verzovaný immutable master: draft lze upravit, `PUBLISHED` a `INACTIVE` se nikdy nepřepisují. Nový design vzniká klonem do nové verze.
- Master PDF je privátní soubor v `MEDIA_STORAGE_ROOT/private/voucher-templates`; databáze drží pouze relativní cestu a SHA-256. Pro first rollout spusť explicitně `npm run voucher:templates:bootstrap` po migraci, nikdy při web requestu.
- `Voucher.templateId`, `VoucherPrintBatch.templateId` a `SiteSettings.voucherDefaultTemplateId` jsou referenční vazby. Default musí odkazovat na publikovanou šablonu; před deaktivací jej změň.
- PRINT (216 × 105 mm), DIGITAL (vektorový crop 210 × 99 mm) i STOCK používají stejný persistentní master. PDF/X-4 conformity renderer neověřuje; preflight proto reportuje `NOT VERIFIED`, ale kontroluje geometrii a čitelnost PDF.
- PRINT (216 × 105 mm), DIGITAL (vektorový crop 210 × 99 mm) i STOCK používají stejný persistentní master. Tiskový preflight strukturálně kontroluje PDF header minimálně 1.6, geometrii, OutputIntent, ICC header vhodný pro CMYK tisk (`N=4`, `acsp`, class, color space, PCS), přesné katalogové PDF/X-4 XMP metadata (`GTS_PDFXVersion=PDF/X-4` bez `GTS_PDFXConformance`), rotaci, encryption a všechny stránky; bez externího PDF/X validátoru reportuje `STRUCTURALLY_VALIDATED`, nikoli externí certifikaci.

### Vývoj e-mailů

Expand Down Expand Up @@ -332,6 +332,7 @@ Při změně schématu dodrž standardní Prisma workflow: před migrací `npx p
- Detail voucheru má navíc read-only sekci `Odeslání e-mailem`, která z `EmailLog` typu `VOUCHER_SENT` vrací jen bezpečnou historii posledních 5 záznamů. Read model nesmí vracet raw payload, `processingToken`, `providerMessageId` ani jiná technická pole; pokud dotaz na historii selže, detail voucheru má dál fungovat a jen zobrazit prázdný stav.
- PDF voucheru běží přes route handlery `/admin/vouchery/[voucherId]/pdf` a `/admin/provoz/vouchery/[voucherId]/pdf`, sdílený handler `createAdminVoucherPdfRoute(...)`, Next.js wrapper `src/features/vouchers/lib/voucher-pdf.ts` se `import "server-only"` a worker-safe core `src/features/vouchers/lib/voucher-pdf-core.ts`. Handler vždy ověřuje session a roli `OWNER` nebo `SALON`, PDF neukládá do DB ani na disk a generuje ho z aktuálního read modelu voucheru.
- Voucherová šablona je po bootstrapu výhradně perzistentní záznam `VoucherTemplate` s privátním masterem v `MEDIA_STORAGE_ROOT`; každý voucher ukládá přesný `templateKey` i `templateId` a renderer historického voucheru nefallbackuje na aktuální default. Seed `classic-v1.pdf` je neveřejný bootstrap asset v `src/features/vouchers/bootstrap-assets/`, který je explicitně zahrnutý do Next output-file trace. Nový design znamená nový key (`classic-v2`, `christmas-v1`), nikoli změnu geometrie `classic-v1`; skutečný runtime master se nikdy nenačítá z `public/`.
- `classic-v2.pdf` je připravený ColorPoint/Fujifilm Revoria uncoated kandidát s embedded profilem z oficiálního ColorPoint URL; jeho finální schválení tiskárnou, instalace do persistentní `VoucherTemplate` a přepnutí defaultu jsou samostatné rollout kroky. Reprodukční příprava používá `npm run voucher:templates:prepare-classic-v2 -- classic-v1.pdf profil.icc classic-v2.pdf` a profil se do repozitáře neukládá samostatně.
- Každá template definition nese vlastní `layout`, včetně page size, TrimBoxu, dynamických oblastí, souřadnic QR a typografických parametrů. Renderer je obecný a pouze řeší `templateKey -> registry -> master + layout`; neobsahuje větvení pro konkrétní designy. DIGITAL a PRINT používají stejný layout, DIGITAL je pouze vektorový crop PRINT stránky. Nová šablona musí projít úplným checklistem výše včetně trusted asset readeru, registry, layoutu a regresních/preflight testů; formulář vytvoření i Nastavení → Vouchery ji pak načtou automaticky z aktivních definition. `allowedTypes` filtruje UI a je znovu vynuceno serverem.
- Preview šablon je lokální asset z registry, nikoli Media Manager; chybějící PNG zobrazí v admin formuláři náhradní stav a neblokuje voucherový backend ani PDF generování.
- Sdílený worker-safe core v `src/features/vouchers/lib/voucher-pdf-core.ts` pro `classic-v1` personalizuje master PRINT stránku 216 × 105 mm s bleedem a TrimBoxem 210 × 99 mm na offsetu 3 mm. Tyto hodnoty jsou uloženy v layoutu `classic-v1` a exportované konstanty v core jsou pouze zpětně kompatibilní aliasy. DIGITAL vzniká vektorovým výřezem stejné PRINT stránky; routy `/pdf` a `/pdf/tisk` zachovávají původní URL kontrakt.
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@
"db:clear-booking-data": "node scripts/clear-booking-data.mjs",
"media:audit": "tsx scripts/media-audit.ts",
"ci:prepare-voucher-fixtures": "node --conditions=react-server --import tsx scripts/prepare-ci-voucher-fixtures.ts",
"voucher:templates:bootstrap": "node --conditions=react-server --env-file=.env --import tsx scripts/bootstrap-voucher-templates.ts"
"voucher:templates:bootstrap": "node --conditions=react-server --env-file=.env --import tsx scripts/bootstrap-voucher-templates.ts",
"voucher:pdf:inspect": "node --conditions=react-server --import tsx scripts/inspect-voucher-pdf.mjs",
"voucher:templates:prepare-classic-v2": "node --conditions=react-server --import tsx scripts/prepare-voucher-classic-v2.mjs"
},
"dependencies": {
"@fontsource/noto-sans": "^5.3.0",
Expand Down
127 changes: 127 additions & 0 deletions scripts/inspect-voucher-pdf.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";

import { PDFArray, PDFDict, PDFDocument, PDFName, PDFRef, PDFStream, decodePDFRawStream } from "pdf-lib";

import { inspectIccProfile, inspectPdfPrepressMetadata } from "../src/features/vouchers/lib/voucher-pdf-prepress.ts";
import { defaultVoucherTemplateLayout } from "../src/features/vouchers/lib/voucher-template-defaults.ts";

const filePath = process.argv[2];
if (!filePath) {
console.error("Použití: npm run voucher:pdf:inspect -- file.pdf");
process.exit(2);
}

const mm = (value) => value * 25.4 / 72;
const formatBox = (box) => `${mm(box.width).toFixed(2)}×${mm(box.height).toFixed(2)} mm @ ${mm(box.x).toFixed(2)}×${mm(box.y).toFixed(2)} mm`;

let pdf;
try {
pdf = await PDFDocument.load(await readFile(filePath), { ignoreEncryption: true, updateMetadata: false });
} catch (error) {
console.error(`PDF nelze načíst: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}

const metadata = inspectPdfPrepressMetadata(pdf);
const pages = pdf.getPages();
const objects = pdf.context.enumerateIndirectObjects();
const streams = objects.filter(([, object]) => object instanceof PDFStream);
const images = streams.filter(([, object]) => object.dict.lookupMaybe(PDFName.of("Subtype"), PDFName)?.asString() === "/Image");
const embeddedFontStreams = streams.filter(([, object]) => object.dict.get(PDFName.of("Length1")) || object.dict.get(PDFName.of("FontFile")) || object.dict.get(PDFName.of("FontFile2")) || object.dict.get(PDFName.of("FontFile3")));
const fontDictionaries = objects.filter(([, object]) => object instanceof PDFDict && object.lookupMaybe(PDFName.of("Type"), PDFName)?.asString() === "/Font");
const bytes = await readFile(filePath);
const pdfVersion = /^%PDF-(\d+\.\d+)/.exec(bytes.subarray(0, 16).toString("latin1"))?.[1] ?? null;
const [pdfMajor, pdfMinor] = pdfVersion?.split(".").map(Number) ?? [];
const pdfVersionValid = Number.isInteger(pdfMajor) && Number.isInteger(pdfMinor) && pdfMajor * 10 + pdfMinor >= 16;
const describe = (object) => {
if (object instanceof PDFName) return object.asString();
if (object instanceof PDFRef) return `${object.objectNumber} ${object.generationNumber} R`;
if (object instanceof PDFArray) return `[${Array.from({ length: object.size() }, (_, index) => describe(object.get(index))).join(" ")}]`;
return object?.constructor?.name ?? String(object);
};
const pageResources = pages[0]?.node.lookupMaybe(PDFName.of("Resources"), PDFDict);
const pageColorSpaces = pageResources?.lookupMaybe(PDFName.of("ColorSpace"), PDFDict);
const colorSpaceEntries = pageColorSpaces
? Array.from(pageColorSpaces.entries()).map(([name, value]) => `${name.asString()}=${describe(value)}`).join(", ")
: "none";
const iccObjects = streams.flatMap(([ref, object]) => {
const n = object.dict.get(PDFName.of("N"));
if (!n) return [];
let profile;
let decodedSize = null;
try {
const decoded = decodePDFRawStream(object).decode();
decodedSize = decoded.length;
profile = inspectIccProfile(decoded);
} catch { profile = null; }
return [{ ref: describe(ref), size: decodedSize, encodedSize: object.getContents().length, channels: n.asNumber?.() ?? null, sha256: profile ? createHash("sha256").update(decodePDFRawStream(object).decode()).digest("hex") : null, profile }];
});
const imageColorSpaces = streams
.filter(([, object]) => object.dict.lookupMaybe(PDFName.of("Subtype"), PDFName)?.asString() === "/Image")
.map(([ref, object]) => `${describe(ref)}=${describe(object.dict.get(PDFName.of("ColorSpace")))}`)
.join(", ");
const trim = defaultVoucherTemplateLayout.trim;
const dynamicAreas = [defaultVoucherTemplateLayout.valueArea, defaultVoucherTemplateLayout.serviceArea, defaultVoucherTemplateLayout.validityArea, defaultVoucherTemplateLayout.codeArea, defaultVoucherTemplateLayout.qrArea];
const dynamicSafeAreaValid = dynamicAreas.every((area) => Math.min(
area.xMm - trim.xMm,
area.yMm - trim.yMm,
trim.xMm + trim.widthMm - (area.xMm + area.widthMm),
trim.yMm + trim.heightMm - (area.yMm + area.heightMm),
) >= 3);
const extGStates = objects.filter(([, object]) => object instanceof PDFDict && Array.from(object.keys()).some((key) => key.asString() === "/CA" || key.asString() === "/ca" || key.asString() === "/BM" || key.asString() === "/SMask"));
const softMasks = extGStates.filter(([, object]) => object.get(PDFName.of("SMask")) && object.get(PDFName.of("SMask"))?.toString() !== "/None");
const blendModes = extGStates.filter(([, object]) => object.get(PDFName.of("BM")) && object.get(PDFName.of("BM"))?.toString() !== "/Normal");
const overprintObjects = objects.filter(([, object]) => object instanceof PDFDict && (object.get(PDFName.of("OP")) || object.get(PDFName.of("op")) || object.get(PDFName.of("OPM"))));
const javaScriptObjects = objects.filter(([, object]) => object instanceof PDFDict && Array.from(object.keys()).some((key) => key.asString() === "/JavaScript"));
const form = pdf.catalog.lookupMaybe(PDFName.of("AcroForm"), PDFDict);
const first = pages[0];
const geometryValid = pages.length > 0 && pages.every((page) => {
const media = page.getMediaBox();
const trim = page.getTrimBox();
const bleed = page.getBleedBox();
const crop = page.getCropBox();
return Math.abs(mm(media.width) - 216) < 0.01
&& Math.abs(mm(media.height) - 105) < 0.01
&& Math.abs(mm(crop.width) - 216) < 0.01
&& Math.abs(mm(crop.height) - 105) < 0.01
&& Math.abs(mm(bleed.width) - 216) < 0.01
&& Math.abs(mm(bleed.height) - 105) < 0.01
&& Math.abs(mm(trim.x) - 3) < 0.01
&& Math.abs(mm(trim.y) - 3) < 0.01
&& Math.abs(mm(trim.width) - 210) < 0.01
&& Math.abs(mm(trim.height) - 99) < 0.01
&& page.getRotation().angle === 0;
});
const pass = !pdf.isEncrypted && pdfVersionValid && geometryValid && metadata.outputIntent.valid && metadata.xmp.valid && !metadata.pdfXConformanceInfoPresent;

console.log(`Pages: ${pages.length}`);
console.log(`PDF version: ${pdfVersion ?? "unknown"} (${pdfVersionValid ? "PASS" : "FAIL"})`);
console.log(`MediaBox: ${first ? formatBox(first.getMediaBox()) : "missing"}`);
console.log(`TrimBox: ${first ? formatBox(first.getTrimBox()) : "missing"}`);
console.log(`BleedBox: ${first ? formatBox(first.getBleedBox()) : "missing"}`);
console.log(`CropBox: ${first ? formatBox(first.getCropBox()) : "missing"}`);
console.log(`Page ColorSpace resources: ${colorSpaceEntries}`);
console.log(`Image ColorSpace usage: ${imageColorSpaces || "none"}`);
console.log(`Dynamic safe area (default voucher layout): ${dynamicSafeAreaValid ? "PASS" : "FAIL"}`);
console.log("Master artwork safe area: NOT AUTOMATICALLY VERIFIED");
console.log(`Rotation: ${pages.every((page) => page.getRotation().angle === 0) ? "PASS" : "FAIL"}`);
console.log(`OutputIntent: ${metadata.outputIntent.valid ? "PASS" : "FAIL"} (${metadata.outputIntent.count})`);
console.log(`ICC profile: ${metadata.outputIntent.profileValid ? "PASS" : "FAIL"}${metadata.outputIntent.outputConditionIdentifier ? ` (${metadata.outputIntent.outputConditionIdentifier})` : ""}`);
for (const icc of iccObjects) {
console.log(`Embedded ICC ${icc.ref}: ${icc.profile?.valid ? "VALID" : "INVALID"}, sha256=${icc.sha256 ?? "unknown"}, decodedSize=${icc.size}, encodedSize=${icc.encodedSize}, N=${icc.channels}, signature=${icc.profile?.signatureValid ? "acsp" : "invalid"}, class=${icc.profile?.profileClass ?? "unknown"}, colorSpace=${icc.profile?.colorSpace ?? "unknown"}, PCS=${icc.profile?.pcs ?? "unknown"}, description=${icc.profile?.description ?? "unknown"}, version=${icc.profile?.version ?? "unknown"}, renderingIntent=${icc.profile?.renderingIntent ?? "unknown"}, tagCount=${icc.profile?.tagCount ?? "unknown"}, copyright=${icc.profile?.copyright ?? "unknown"}, manufacturer=${icc.profile?.manufacturer ?? "unknown"}, model=${icc.profile?.model ?? "unknown"}`);
}
console.log(`PDF/X declaration: ${metadata.xmp.claim ?? "missing"}`);
console.log(`PDF/X properties: ${metadata.xmp.pdfxidProperties.join(", ") || "none"}`);
console.log(`PDF/X conformance Info key: ${metadata.pdfXConformanceInfoPresent ? "FAIL" : "PASS"}`);
console.log(`PDF/X structural checks: ${pass ? "PASS" : "FAIL"}`);
console.log(`Fonts: ${fontDictionaries.length} dictionaries, ${embeddedFontStreams.length} embedded streams`);
console.log(`Images: ${images.length}`);
console.log(`Transparency: ExtGState=${extGStates.length}, soft masks=${softMasks.length}, non-Normal blend modes=${blendModes.length}, overprint dictionaries=${overprintObjects.length}`);
console.log(`Security: encryption=${pdf.isEncrypted ? "YES" : "NO"}, forms=${form ? "YES" : "NO"}, JavaScript objects=${javaScriptObjects.length}`);
console.log(`File size: ${bytes.length} bytes`);
console.log(`ICC streams: ${objects.filter(([, object]) => object instanceof PDFStream && object.dict.get(PDFName.of("N"))).length}`);
console.log(`PDF objects: ${objects.length}, streams: ${streams.length}`);
console.log(`RESULT: ${pass ? "PASS" : "FAIL"}`);

if (!pass) process.exitCode = 1;
9 changes: 8 additions & 1 deletion scripts/prepare-ci-voucher-fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import "dotenv/config";

import { readFile } from "node:fs/promises";
import path from "node:path";

import { AdminRole } from "@/generated/prisma/client";
import { bootstrapVoucherTemplates } from "@/features/vouchers/lib/voucher-template-bootstrap";
import { hashPassword } from "@/lib/auth/password";
Expand Down Expand Up @@ -48,7 +51,11 @@ async function prepareCiVoucherFixtures() {
},
});

const result = await bootstrapVoucherTemplates();
// CI používá legacy key classic-v1 kvůli existujícím fixture scénářům, ale
// musí testovat současný strict PDF/X master. Historický classic-v1.pdf je
// záměrně PDF 1.4 a nesmí se kvůli CI ani produkčnímu bootstrapu přepisovat.
const strictCiMaster = await readFile(path.join(process.cwd(), "src", "features", "vouchers", "bootstrap-assets", "classic-v2.pdf"));
const result = await bootstrapVoucherTemplates({ readMaster: async () => strictCiMaster });
console.info(
`CI voucher fixtures připraveny; backfill=${result.backfilledVouchers}; remainingNulls=${result.remainingNulls}.`,
);
Expand Down
Loading
Loading