From 658516d77016d1df6d48294826791f94edf16e7f Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:25:45 +0200 Subject: [PATCH 01/13] =?UTF-8?q?Oprav=20first-rollout=20bootstrap=20vouch?= =?UTF-8?q?erov=C3=BDch=20=C5=A1ablon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/voucher-template-bootstrap.ts | 400 +++++++++++++----- 1 file changed, 299 insertions(+), 101 deletions(-) diff --git a/src/features/vouchers/lib/voucher-template-bootstrap.ts b/src/features/vouchers/lib/voucher-template-bootstrap.ts index a9865753..5577f0b8 100644 --- a/src/features/vouchers/lib/voucher-template-bootstrap.ts +++ b/src/features/vouchers/lib/voucher-template-bootstrap.ts @@ -20,12 +20,15 @@ import { import { validateVoucherTemplatePreviewPng } from "./voucher-template-preview-validation"; import { CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY } from "./voucher-template-validation-policy"; -const CLASSIC_TEMPLATE_KEY = "classic-v1"; -const BOOTSTRAP_LOCK = "ppstudio:voucher-template-bootstrap:classic-v1"; +const LEGACY_TEMPLATE_KEY = "classic-v1"; +const CURRENT_TEMPLATE_KEY = "classic-v2"; +const TEMPLATE_FAMILY_KEY = "classic"; +const BOOTSTRAP_LOCK = "ppstudio:voucher-template-bootstrap:classic"; export type VoucherTemplateBootstrapDependencies = { db?: typeof prisma; - readMaster?: () => Promise; + readLegacyMaster?: () => Promise; + readCurrentMaster?: () => Promise; writeMaster?: typeof writeVoucherTemplateMaster; writePreview?: typeof writeVoucherTemplatePreview; readStoredMaster?: typeof readVoucherTemplateMaster; @@ -50,7 +53,7 @@ function assertPdfSignature(master: Buffer) { } } -async function validateMaster(master: Buffer, preflightMaster: typeof preflightVoucherTemplateMaster) { +async function validateStrictMaster(master: Buffer, preflightMaster: typeof preflightVoucherTemplateMaster) { assertPdfSignature(master); const preflight = await preflightMaster(master); if (preflight.errors.length) { @@ -58,9 +61,14 @@ async function validateMaster(master: Buffer, preflightMaster: typeof preflightV } } +async function readBundledMaster(fileName: string) { + return readFile(path.join(process.cwd(), "src", "features", "vouchers", "bootstrap-assets", fileName)); +} + async function bootstrapOnce(dependencies: VoucherTemplateBootstrapDependencies) { const db = dependencies.db ?? prisma; - const readMaster = dependencies.readMaster ?? (() => readFile(path.join(process.cwd(), "src", "features", "vouchers", "bootstrap-assets", "classic-v1.pdf"))); + const readLegacyMaster = dependencies.readLegacyMaster ?? (() => readBundledMaster("classic-v1.pdf")); + const readCurrentMaster = dependencies.readCurrentMaster ?? (() => readBundledMaster("classic-v2.pdf")); const writeMaster = dependencies.writeMaster ?? writeVoucherTemplateMaster; const writePreview = dependencies.writePreview ?? writeVoucherTemplatePreview; const readStoredMaster = dependencies.readStoredMaster ?? readVoucherTemplateMaster; @@ -72,186 +80,378 @@ async function bootstrapOnce(dependencies: VoucherTemplateBootstrapDependencies) const validatePreview = dependencies.validatePreview ?? validateVoucherTemplatePreviewPng; const deleteAsset = dependencies.deleteAsset ?? deleteVoucherTemplateAsset; const stagedAssets: string[] = []; - let createdTemplateId: string | null = null; - let repairedPreviewToCleanup: string | null = null; + const createdTemplateIds: string[] = []; + const repairedPreviewsToCleanup: string[] = []; try { const result = await db.$transaction(async (tx) => { - // Serializuje i první vytvoření classic-v1, takže dva bootstrap procesy - // neuvidí současně prázdný namespace a nesoutěží o unique key. if (typeof tx.$queryRaw === "function") { - // pg_advisory_xact_lock() vrací PostgreSQL typ `void`, který Prisma - // neumí deserializovat jako výsledek dotazu. Vyhodnocení přes IS NULL - // zachová blokující lock a vrátí běžný boolean. await tx.$queryRaw(Prisma.sql`SELECT pg_advisory_xact_lock(hashtext(${BOOTSTRAP_LOCK})) IS NULL AS locked`); } - let template = await tx.voucherTemplate.findUnique({ where: { key: CLASSIC_TEMPLATE_KEY } }); let owner: { id: string } | null = null; - let fresh = false; - - if (!template) { - owner = await tx.adminUser.findFirst({ where: { role: AdminRole.OWNER, isActive: true }, select: { id: true } }); + const requireOwner = async () => { + if (owner) return owner; + owner = await tx.adminUser.findFirst({ + where: { role: AdminRole.OWNER, isActive: true }, + select: { id: true }, + }); if (!owner) throw new Error("Bootstrap vyžaduje aktivního OWNER uživatele."); + return owner; + }; - template = await tx.voucherTemplate.create({ + let legacyTemplate = await tx.voucherTemplate.findUnique({ where: { key: LEGACY_TEMPLATE_KEY } }); + let currentTemplate = await tx.voucherTemplate.findUnique({ where: { key: CURRENT_TEMPLATE_KEY } }); + let legacyFresh = false; + let currentFresh = false; + + if (!legacyTemplate) { + const actor = await requireOwner(); + legacyTemplate = await tx.voucherTemplate.create({ data: { - key: CLASSIC_TEMPLATE_KEY, - familyKey: "classic", + key: LEGACY_TEMPLATE_KEY, + familyKey: TEMPLATE_FAMILY_KEY, version: 1, + label: "Klasický (historický)", + status: VoucherTemplateStatus.DRAFT, + allowedTypes: [VoucherType.VALUE, VoucherType.SERVICE], + layout: defaultVoucherTemplateLayout, + createdByUserId: actor.id, + }, + }); + createdTemplateIds.push(legacyTemplate.id); + legacyFresh = true; + } + + if (!currentTemplate) { + const actor = await requireOwner(); + currentTemplate = await tx.voucherTemplate.create({ + data: { + key: CURRENT_TEMPLATE_KEY, + familyKey: TEMPLATE_FAMILY_KEY, + version: 2, label: "Klasický", status: VoucherTemplateStatus.DRAFT, allowedTypes: [VoucherType.VALUE, VoucherType.SERVICE], layout: defaultVoucherTemplateLayout, - createdByUserId: owner.id, + createdByUserId: actor.id, + clonedFromId: legacyTemplate.id, }, }); - createdTemplateId = template.id; - fresh = true; + createdTemplateIds.push(currentTemplate.id); + currentFresh = true; + } + + if (legacyFresh) { + const actor = await requireOwner(); + const master = await readLegacyMaster(); + // classic-v1 je záměrně historický PDF 1.4. Na first rollout jej + // zachováme pro reprint starých voucherů, ale nikdy ho neoznačíme + // STRICT_V1 ani nenabídneme pro nové vydání. + assertPdfSignature(master); + const preview = await renderPreview(master); + const storedMaster = await writeMaster(legacyTemplate.id, master); + stagedAssets.push(storedMaster.storagePath); + const storedPreview = await writePreview(legacyTemplate.id, preview); + stagedAssets.push(storedPreview.storagePath); + const now = new Date(); + + legacyTemplate = await tx.voucherTemplate.update({ + where: { id: legacyTemplate.id }, + data: { + status: VoucherTemplateStatus.INACTIVE, + validationPolicy: null, + masterStoragePath: storedMaster.storagePath, + masterSha256: storedMaster.sha256, + previewStoragePath: storedPreview.storagePath, + publishedByUserId: actor.id, + publishedAt: now, + inactivatedByUserId: actor.id, + inactivatedAt: now, + }, + }); + + await tx.voucherTemplateAuditLog.create({ + data: { + templateId: legacyTemplate.id, + templateKey: legacyTemplate.key, + familyKey: legacyTemplate.familyKey, + version: legacyTemplate.version, + label: legacyTemplate.label, + actorUserId: actor.id, + operation: "PUBLISH", + metadata: { bootstrap: true, historical: true }, + }, + }); + await tx.voucherTemplateAuditLog.create({ + data: { + templateId: legacyTemplate.id, + templateKey: legacyTemplate.key, + familyKey: legacyTemplate.familyKey, + version: legacyTemplate.version, + label: legacyTemplate.label, + actorUserId: actor.id, + operation: "DEACTIVATE", + metadata: { bootstrap: true, historical: true }, + }, + }); + } else { + const allowedStatus = legacyTemplate.status === VoucherTemplateStatus.PUBLISHED + || legacyTemplate.status === VoucherTemplateStatus.INACTIVE; + if (!allowedStatus || !legacyTemplate.masterStoragePath || !legacyTemplate.masterSha256) { + throw new Error("Bootstrap nalezl classic-v1 bez publikovaného historického masteru."); + } + if (!(await masterExists(legacyTemplate.masterStoragePath))) { + throw new Error("Bootstrap nalezl chybějící master classic-v1."); + } + + let master: Buffer; + try { + master = await readStoredMaster(legacyTemplate.masterStoragePath); + } catch { + throw new Error("Bootstrap nedokázal načíst master classic-v1."); + } + if (sha256(master) !== legacyTemplate.masterSha256) { + throw new Error("Bootstrap nalezl nesouhlasící kontrolní součet masteru classic-v1."); + } + + if (legacyTemplate.validationPolicy === CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY) { + await validateStrictMaster(master, preflightMaster); + } else { + assertPdfSignature(master); + } + + let previewIsValid = false; + if (legacyTemplate.previewStoragePath) { + try { + const existingPreview = await readStoredPreview(legacyTemplate.previewStoragePath); + previewIsValid = (await validatePreview(existingPreview)).ok; + } catch { + previewIsValid = false; + } + } + + if (!previewIsValid) { + const previousPreviewStoragePath = legacyTemplate.previewStoragePath; + const preview = await renderPreview(master); + const storedPreview = await writePreview(legacyTemplate.id, preview); + stagedAssets.push(storedPreview.storagePath); + + const updated = await tx.voucherTemplate.updateMany({ + where: { + id: legacyTemplate.id, + status: legacyTemplate.status, + masterStoragePath: legacyTemplate.masterStoragePath, + masterSha256: legacyTemplate.masterSha256, + previewStoragePath: previousPreviewStoragePath, + }, + data: { previewStoragePath: storedPreview.storagePath }, + }); + if (updated.count !== 1) { + throw new Error("Bootstrap nemohl atomicky opravit preview classic-v1."); + } + if (previousPreviewStoragePath) repairedPreviewsToCleanup.push(previousPreviewStoragePath); + legacyTemplate = { ...legacyTemplate, previewStoragePath: storedPreview.storagePath }; + } } - if (fresh) { - const master = await readMaster(); - await validateMaster(master, preflightMaster); + if (currentFresh) { + const actor = await requireOwner(); + const master = await readCurrentMaster(); + await validateStrictMaster(master, preflightMaster); const preview = await renderPreview(master); - const storedMaster = await writeMaster(template.id, master); + const storedMaster = await writeMaster(currentTemplate.id, master); stagedAssets.push(storedMaster.storagePath); - const storedPreview = await writePreview(template.id, preview); + const storedPreview = await writePreview(currentTemplate.id, preview); stagedAssets.push(storedPreview.storagePath); - const layout = voucherTemplateLayoutSchema.parse(template.layout); + const layout = voucherTemplateLayoutSchema.parse(currentTemplate.layout); const finalPreflight = await preflightPublish({ - id: template.id, - key: template.key, - label: template.label, - status: template.status, - allowedTypes: template.allowedTypes, + id: currentTemplate.id, + key: currentTemplate.key, + label: currentTemplate.label, + status: currentTemplate.status, + allowedTypes: currentTemplate.allowedTypes, layout, masterSha256: storedMaster.sha256, masterBytes: master, }); - if (!finalPreflight.ok) throw new Error(finalPreflight.errors[0] ?? "Finální PDF preflight šablony neprošel."); + if (!finalPreflight.ok) { + throw new Error(finalPreflight.errors[0] ?? "Finální PDF preflight šablony neprošel."); + } - template = await tx.voucherTemplate.update({ - where: { id: template.id }, + currentTemplate = await tx.voucherTemplate.update({ + where: { id: currentTemplate.id }, data: { status: VoucherTemplateStatus.PUBLISHED, validationPolicy: CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY, masterStoragePath: storedMaster.storagePath, masterSha256: storedMaster.sha256, previewStoragePath: storedPreview.storagePath, - publishedByUserId: owner!.id, + publishedByUserId: actor.id, publishedAt: new Date(), }, }); + + await tx.voucherTemplateAuditLog.create({ + data: { + templateId: currentTemplate.id, + templateKey: currentTemplate.key, + familyKey: currentTemplate.familyKey, + version: currentTemplate.version, + label: currentTemplate.label, + actorUserId: actor.id, + operation: "PUBLISH", + metadata: { bootstrap: true, current: true }, + }, + }); } else { - const isPublished = template.status === VoucherTemplateStatus.PUBLISHED; - const isHistoricalInactive = template.status === VoucherTemplateStatus.INACTIVE; - if ((!isPublished && !isHistoricalInactive) || !template.masterStoragePath || !template.masterSha256) { - throw new Error("Bootstrap nalezl classic-v1 bez publikovaného a platného masteru."); + const allowedStatus = currentTemplate.status === VoucherTemplateStatus.PUBLISHED + || currentTemplate.status === VoucherTemplateStatus.INACTIVE; + if (!allowedStatus || !currentTemplate.masterStoragePath || !currentTemplate.masterSha256) { + throw new Error("Bootstrap nalezl classic-v2 bez publikovaného a platného masteru."); } - - if (!(await masterExists(template.masterStoragePath))) { - throw new Error("Bootstrap nalezl chybějící master classic-v1."); + if (currentTemplate.validationPolicy !== CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY) { + throw new Error("Bootstrap nalezl classic-v2 bez aktuální strict validation policy."); + } + if (!(await masterExists(currentTemplate.masterStoragePath))) { + throw new Error("Bootstrap nalezl chybějící master classic-v2."); } + let master: Buffer; try { - master = await readStoredMaster(template.masterStoragePath); + master = await readStoredMaster(currentTemplate.masterStoragePath); } catch { - throw new Error("Bootstrap nedokázal načíst master classic-v1."); + throw new Error("Bootstrap nedokázal načíst master classic-v2."); } - if (sha256(master) !== template.masterSha256) { - throw new Error("Bootstrap nalezl nesouhlasící kontrolní součet masteru classic-v1."); + if (sha256(master) !== currentTemplate.masterSha256) { + throw new Error("Bootstrap nalezl nesouhlasící kontrolní součet masteru classic-v2."); } - await validateMaster(master, preflightMaster); + await validateStrictMaster(master, preflightMaster); let previewIsValid = false; - if (template.previewStoragePath) { + if (currentTemplate.previewStoragePath) { try { - const existingPreview = await readStoredPreview(template.previewStoragePath); + const existingPreview = await readStoredPreview(currentTemplate.previewStoragePath); previewIsValid = (await validatePreview(existingPreview)).ok; } catch { previewIsValid = false; } } - if (!previewIsValid && isHistoricalInactive) { - throw new Error("Bootstrap nalezl neplatný nebo chybějící preview classic-v1."); - } - if (!previewIsValid) { - const previousPreviewStoragePath = template.previewStoragePath; + const previousPreviewStoragePath = currentTemplate.previewStoragePath; const preview = await renderPreview(master); - const storedPreview = await writePreview(template.id, preview); + const storedPreview = await writePreview(currentTemplate.id, preview); stagedAssets.push(storedPreview.storagePath); const updated = await tx.voucherTemplate.updateMany({ where: { - id: template.id, - status: VoucherTemplateStatus.PUBLISHED, - masterStoragePath: template.masterStoragePath, - masterSha256: template.masterSha256, + id: currentTemplate.id, + status: currentTemplate.status, + validationPolicy: CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY, + masterStoragePath: currentTemplate.masterStoragePath, + masterSha256: currentTemplate.masterSha256, previewStoragePath: previousPreviewStoragePath, }, data: { previewStoragePath: storedPreview.storagePath }, }); if (updated.count !== 1) { - throw new Error("Bootstrap nemohl atomicky opravit preview classic-v1."); + throw new Error("Bootstrap nemohl atomicky opravit preview classic-v2."); } - repairedPreviewToCleanup = previousPreviewStoragePath; - template = { ...template, previewStoragePath: storedPreview.storagePath }; + if (previousPreviewStoragePath) repairedPreviewsToCleanup.push(previousPreviewStoragePath); + currentTemplate = { ...currentTemplate, previewStoragePath: storedPreview.storagePath }; } } - const legacyVouchers = await tx.voucher.findMany({ where: { templateId: null }, select: { templateKey: true } }); - const ambiguous = legacyVouchers.filter((voucher) => voucher.templateKey !== CLASSIC_TEMPLATE_KEY); - if (ambiguous.length > 0) throw new Error(`Bootstrap nemůže bezpečně určit šablonu pro ${ambiguous.length} historických voucherů.`); + const legacyVouchers = await tx.voucher.findMany({ + where: { templateId: null }, + select: { templateKey: true }, + }); + const ambiguous = legacyVouchers.filter((voucher) => voucher.templateKey !== LEGACY_TEMPLATE_KEY); + if (ambiguous.length > 0) { + throw new Error(`Bootstrap nemůže bezpečně určit šablonu pro ${ambiguous.length} historických voucherů.`); + } + + const backfilled = await tx.voucher.updateMany({ + where: { templateId: null, templateKey: LEGACY_TEMPLATE_KEY }, + data: { templateId: legacyTemplate.id }, + }); - const backfilled = await tx.voucher.updateMany({ where: { templateId: null, templateKey: CLASSIC_TEMPLATE_KEY }, data: { templateId: template.id } }); - const settings = await tx.siteSettings.findUnique({ where: { id: "site-settings" }, select: { voucherDefaultTemplateId: true } }); + const settings = await tx.siteSettings.findUnique({ + where: { id: "site-settings" }, + select: { voucherDefaultTemplateId: true }, + }); if (!settings) throw new Error("Bootstrap nenalezl očekávaný singleton SiteSettings."); - if (settings.voucherDefaultTemplateId === null) { - if (template.status === VoucherTemplateStatus.INACTIVE) { - throw new Error("Bootstrap nemůže nastavit neaktivní classic-v1 jako výchozí šablonu."); + let defaultTemplateId = settings.voucherDefaultTemplateId; + if (defaultTemplateId === null) { + if ( + currentTemplate.status !== VoucherTemplateStatus.PUBLISHED + || currentTemplate.validationPolicy !== CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY + ) { + throw new Error("Bootstrap nemůže nastavit classic-v2 jako výchozí šablonu."); } const updatedSettings = await tx.siteSettings.updateMany({ where: { id: "site-settings", voucherDefaultTemplateId: null }, - data: { voucherDefaultTemplateId: template.id }, + data: { voucherDefaultTemplateId: currentTemplate.id }, }); - if (updatedSettings.count !== 1) throw new Error("Bootstrap nedokázal nastavit výchozí šablonu v SiteSettings."); - } else if (settings.voucherDefaultTemplateId !== template.id) { - const configured = await tx.voucherTemplate.findUnique({ where: { id: settings.voucherDefaultTemplateId }, select: { status: true } }); - if (!configured || configured.status !== VoucherTemplateStatus.PUBLISHED) { + if (updatedSettings.count !== 1) { + throw new Error("Bootstrap nedokázal nastavit výchozí šablonu v SiteSettings."); + } + defaultTemplateId = currentTemplate.id; + } else if (defaultTemplateId === legacyTemplate.id) { + const legacyIsIssuable = legacyTemplate.status === VoucherTemplateStatus.PUBLISHED + && legacyTemplate.validationPolicy === CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY; + if (!legacyIsIssuable) { + if ( + currentTemplate.status !== VoucherTemplateStatus.PUBLISHED + || currentTemplate.validationPolicy !== CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY + ) { + throw new Error("Bootstrap nemá platnou strict šablonu pro změnu výchozího vzhledu."); + } + const updatedSettings = await tx.siteSettings.updateMany({ + where: { id: "site-settings", voucherDefaultTemplateId: legacyTemplate.id }, + data: { voucherDefaultTemplateId: currentTemplate.id }, + }); + if (updatedSettings.count !== 1) { + throw new Error("Bootstrap nedokázal přepnout výchozí šablonu na classic-v2."); + } + defaultTemplateId = currentTemplate.id; + } + } else { + const configured = await tx.voucherTemplate.findUnique({ + where: { id: defaultTemplateId }, + select: { status: true, validationPolicy: true }, + }); + if ( + !configured + || configured.status !== VoucherTemplateStatus.PUBLISHED + || configured.validationPolicy !== CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY + ) { throw new Error("SiteSettings odkazuje na neplatnou výchozí voucherovou šablonu."); } } const remainingNulls = await tx.voucher.count({ where: { templateId: null } }); - if (remainingNulls !== 0) throw new Error(`Bootstrap skončil s ${remainingNulls} voucher řádky bez templateId.`); - if (fresh) { - await tx.voucherTemplateAuditLog.create({ - data: { - templateId: template.id, - templateKey: template.key, - familyKey: template.familyKey, - version: template.version, - label: template.label, - actorUserId: owner!.id, - operation: "PUBLISH", - metadata: { bootstrap: true, backfilledVouchers: backfilled.count }, - }, - }); + if (remainingNulls !== 0) { + throw new Error(`Bootstrap skončil s ${remainingNulls} voucher řádky bez templateId.`); } - return { backfilledVouchers: backfilled.count, remainingNulls }; + return { + backfilledVouchers: backfilled.count, + remainingNulls, + legacyTemplateId: legacyTemplate.id, + currentTemplateId: currentTemplate.id, + defaultTemplateId, + }; }, { timeout: 30_000 }); - if (repairedPreviewToCleanup) { - await deleteAsset(repairedPreviewToCleanup).catch((cleanupError) => { + for (const storagePath of repairedPreviewsToCleanup) { + await deleteAsset(storagePath).catch((cleanupError) => { console.warn("Bootstrap cleanup starého preview selhal po úspěšném přepnutí", { - storagePath: repairedPreviewToCleanup, + storagePath, cleanupError, }); }); @@ -264,9 +464,9 @@ async function bootstrapOnce(dependencies: VoucherTemplateBootstrapDependencies) console.error("Bootstrap cleanup assetu selhal", { storagePath, cleanupError }); }); } - if (createdTemplateId) { - await db.voucherTemplate.delete({ where: { id: createdTemplateId } }).catch((cleanupError) => { - console.error("Bootstrap cleanup DB šablony selhal", { cleanupError }); + for (const templateId of createdTemplateIds.reverse()) { + await db.voucherTemplate.delete({ where: { id: templateId } }).catch((cleanupError) => { + console.error("Bootstrap cleanup DB šablony selhal", { templateId, cleanupError }); }); } throw error; @@ -278,8 +478,6 @@ export async function bootstrapVoucherTemplates(dependencies: VoucherTemplateBoo return await bootstrapOnce(dependencies); } catch (error) { if (isClassicUniqueConflict(error)) { - // Fallback pro databáze bez advisory-lock podpory; druhý proces už - // pouze načte vítězný classic-v1 a provede idempotentní backfill. return bootstrapOnce(dependencies); } throw error; From 1b9b8c64e36a9e1253d6b3903f7c79756324c6fd Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:26:06 +0200 Subject: [PATCH 02/13] =?UTF-8?q?Testuj=20v=20CI=20produk=C4=8Dn=C3=AD=20v?= =?UTF-8?q?oucher=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/prepare-ci-voucher-fixtures.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/scripts/prepare-ci-voucher-fixtures.ts b/scripts/prepare-ci-voucher-fixtures.ts index 113812bc..37b60b6a 100644 --- a/scripts/prepare-ci-voucher-fixtures.ts +++ b/scripts/prepare-ci-voucher-fixtures.ts @@ -1,8 +1,5 @@ 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"; @@ -51,13 +48,11 @@ async function prepareCiVoucherFixtures() { }, }); - // 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 }); + // CI používá stejnou bootstrap cestu jako production first rollout. + // Ověřuje tak historický classic-v1 i strict classic-v2 bez test-only override. + const result = await bootstrapVoucherTemplates(); console.info( - `CI voucher fixtures připraveny; backfill=${result.backfilledVouchers}; remainingNulls=${result.remainingNulls}.`, + `CI voucher fixtures připraveny; backfill=${result.backfilledVouchers}; remainingNulls=${result.remainingNulls}; default=${result.defaultTemplateId}.`, ); } From 3f3dc0b99704fd1e4cdd160de0c76f67d9e98e56 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:26:21 +0200 Subject: [PATCH 03/13] Stabilizuj admin PWA E2E asset kontrolu --- tests/e2e/admin-pwa.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/e2e/admin-pwa.spec.ts b/tests/e2e/admin-pwa.spec.ts index 11c9700e..dcfd2eeb 100644 --- a/tests/e2e/admin-pwa.spec.ts +++ b/tests/e2e/admin-pwa.spec.ts @@ -15,11 +15,14 @@ async function loginAdmin(page: Page, email: string, password: string) { } test("admin PWA assety mají bezpečné hlavičky a veřejná rezervace není v jejím scope", async ({ page, request }) => { - const [manifest, worker, offline] = await Promise.all([ - request.get("/admin.webmanifest"), - request.get("/admin-sw.js"), - request.get("/admin-offline.html"), - ]); + // Assety čteme sekvenčně: test neověřuje paralelní obsluhu a souběžné + // requesty při rozběhu Next serveru dříve občas končily socket hang up. + const manifest = await request.get("/admin.webmanifest"); + const worker = await request.get("/admin-sw.js"); + const offline = await request.get("/admin-offline.html"); + expect(manifest.ok()).toBe(true); + expect(worker.ok()).toBe(true); + expect(offline.ok()).toBe(true); expect(manifest.headers()["content-type"]).toContain("application/manifest+json"); const manifestData = await manifest.json(); From ec6e3f1f1eda1b3498933aa3ea6af009da96707f Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:27:20 +0200 Subject: [PATCH 04/13] =?UTF-8?q?Zachovej=20testovac=C3=AD=20single-templa?= =?UTF-8?q?te=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/voucher-template-bootstrap.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/src/features/vouchers/lib/voucher-template-bootstrap.ts b/src/features/vouchers/lib/voucher-template-bootstrap.ts index 5577f0b8..19620ff7 100644 --- a/src/features/vouchers/lib/voucher-template-bootstrap.ts +++ b/src/features/vouchers/lib/voucher-template-bootstrap.ts @@ -27,6 +27,8 @@ const BOOTSTRAP_LOCK = "ppstudio:voucher-template-bootstrap:classic"; export type VoucherTemplateBootstrapDependencies = { db?: typeof prisma; + /** Test/backward-compatible single-template override; production bootstrap ho nepoužívá. */ + readMaster?: () => Promise; readLegacyMaster?: () => Promise; readCurrentMaster?: () => Promise; writeMaster?: typeof writeVoucherTemplateMaster; @@ -65,7 +67,155 @@ async function readBundledMaster(fileName: string) { return readFile(path.join(process.cwd(), "src", "features", "vouchers", "bootstrap-assets", fileName)); } +async function bootstrapSingleTemplateCompatibility(dependencies: VoucherTemplateBootstrapDependencies) { + const db = dependencies.db ?? prisma; + const readMaster = dependencies.readMaster!; + const writeMaster = dependencies.writeMaster ?? writeVoucherTemplateMaster; + const writePreview = dependencies.writePreview ?? writeVoucherTemplatePreview; + const readStoredMaster = dependencies.readStoredMaster ?? readVoucherTemplateMaster; + const readStoredPreview = dependencies.readStoredPreview ?? readVoucherTemplatePreview; + const masterExists = dependencies.masterExists ?? voucherTemplateMasterExists; + const preflightMaster = dependencies.preflightMaster ?? preflightVoucherTemplateMaster; + const preflightPublish = dependencies.preflightPublish ?? preflightVoucherTemplateForPublish; + const renderPreview = dependencies.renderPreview ?? renderVoucherTemplatePreview; + const validatePreview = dependencies.validatePreview ?? validateVoucherTemplatePreviewPng; + const deleteAsset = dependencies.deleteAsset ?? deleteVoucherTemplateAsset; + const stagedAssets: string[] = []; + let createdTemplateId: string | null = null; + let repairedPreviewToCleanup: string | null = null; + + try { + const result = await db.$transaction(async (tx) => { + if (typeof tx.$queryRaw === "function") { + await tx.$queryRaw(Prisma.sql`SELECT pg_advisory_xact_lock(hashtext(${BOOTSTRAP_LOCK})) IS NULL AS locked`); + } + + let template = await tx.voucherTemplate.findUnique({ where: { key: LEGACY_TEMPLATE_KEY } }); + let owner: { id: string } | null = null; + let fresh = false; + + if (!template) { + owner = await tx.adminUser.findFirst({ where: { role: AdminRole.OWNER, isActive: true }, select: { id: true } }); + if (!owner) throw new Error("Bootstrap vyžaduje aktivního OWNER uživatele."); + template = await tx.voucherTemplate.create({ + data: { + key: LEGACY_TEMPLATE_KEY, + familyKey: TEMPLATE_FAMILY_KEY, + version: 1, + label: "Klasický", + status: VoucherTemplateStatus.DRAFT, + allowedTypes: [VoucherType.VALUE, VoucherType.SERVICE], + layout: defaultVoucherTemplateLayout, + createdByUserId: owner.id, + }, + }); + createdTemplateId = template.id; + fresh = true; + } + + if (fresh) { + const master = await readMaster(); + await validateStrictMaster(master, preflightMaster); + const preview = await renderPreview(master); + const storedMaster = await writeMaster(template.id, master); + stagedAssets.push(storedMaster.storagePath); + const storedPreview = await writePreview(template.id, preview); + stagedAssets.push(storedPreview.storagePath); + const layout = voucherTemplateLayoutSchema.parse(template.layout); + const finalPreflight = await preflightPublish({ + id: template.id, + key: template.key, + label: template.label, + status: template.status, + allowedTypes: template.allowedTypes, + layout, + masterSha256: storedMaster.sha256, + masterBytes: master, + }); + if (!finalPreflight.ok) throw new Error(finalPreflight.errors[0] ?? "Finální PDF preflight šablony neprošel."); + template = await tx.voucherTemplate.update({ + where: { id: template.id }, + data: { + status: VoucherTemplateStatus.PUBLISHED, + validationPolicy: CURRENT_VOUCHER_TEMPLATE_VALIDATION_POLICY, + masterStoragePath: storedMaster.storagePath, + masterSha256: storedMaster.sha256, + previewStoragePath: storedPreview.storagePath, + publishedByUserId: owner!.id, + publishedAt: new Date(), + }, + }); + } else { + const allowedStatus = template.status === VoucherTemplateStatus.PUBLISHED || template.status === VoucherTemplateStatus.INACTIVE; + if (!allowedStatus || !template.masterStoragePath || !template.masterSha256) throw new Error("Bootstrap nalezl classic-v1 bez publikovaného a platného masteru."); + if (!(await masterExists(template.masterStoragePath))) throw new Error("Bootstrap nalezl chybějící master classic-v1."); + let master: Buffer; + try { master = await readStoredMaster(template.masterStoragePath); } + catch { throw new Error("Bootstrap nedokázal načíst master classic-v1."); } + if (sha256(master) !== template.masterSha256) throw new Error("Bootstrap nalezl nesouhlasící kontrolní součet masteru classic-v1."); + await validateStrictMaster(master, preflightMaster); + + let previewIsValid = false; + if (template.previewStoragePath) { + try { previewIsValid = (await validatePreview(await readStoredPreview(template.previewStoragePath))).ok; } + catch { previewIsValid = false; } + } + if (!previewIsValid && template.status === VoucherTemplateStatus.INACTIVE) { + throw new Error("Bootstrap nalezl neplatný nebo chybějící preview classic-v1."); + } + if (!previewIsValid) { + const previousPreviewStoragePath = template.previewStoragePath; + const storedPreview = await writePreview(template.id, await renderPreview(master)); + stagedAssets.push(storedPreview.storagePath); + const updated = await tx.voucherTemplate.updateMany({ + where: { id: template.id, status: VoucherTemplateStatus.PUBLISHED, masterStoragePath: template.masterStoragePath, masterSha256: template.masterSha256, previewStoragePath: previousPreviewStoragePath }, + data: { previewStoragePath: storedPreview.storagePath }, + }); + if (updated.count !== 1) throw new Error("Bootstrap nemohl atomicky opravit preview classic-v1."); + repairedPreviewToCleanup = previousPreviewStoragePath; + template = { ...template, previewStoragePath: storedPreview.storagePath }; + } + } + + const legacyVouchers = await tx.voucher.findMany({ where: { templateId: null }, select: { templateKey: true } }); + const ambiguous = legacyVouchers.filter((voucher) => voucher.templateKey !== LEGACY_TEMPLATE_KEY); + if (ambiguous.length > 0) throw new Error(`Bootstrap nemůže bezpečně určit šablonu pro ${ambiguous.length} historických voucherů.`); + const backfilled = await tx.voucher.updateMany({ where: { templateId: null, templateKey: LEGACY_TEMPLATE_KEY }, data: { templateId: template.id } }); + const settings = await tx.siteSettings.findUnique({ where: { id: "site-settings" }, select: { voucherDefaultTemplateId: true } }); + if (!settings) throw new Error("Bootstrap nenalezl očekávaný singleton SiteSettings."); + + if (settings.voucherDefaultTemplateId === null) { + if (template.status === VoucherTemplateStatus.INACTIVE) throw new Error("Bootstrap nemůže nastavit neaktivní classic-v1 jako výchozí šablonu."); + const updatedSettings = await tx.siteSettings.updateMany({ where: { id: "site-settings", voucherDefaultTemplateId: null }, data: { voucherDefaultTemplateId: template.id } }); + if (updatedSettings.count !== 1) throw new Error("Bootstrap nedokázal nastavit výchozí šablonu v SiteSettings."); + } else if (settings.voucherDefaultTemplateId !== template.id) { + const configured = await tx.voucherTemplate.findUnique({ where: { id: settings.voucherDefaultTemplateId }, select: { status: true } }); + if (!configured || configured.status !== VoucherTemplateStatus.PUBLISHED) throw new Error("SiteSettings odkazuje na neplatnou výchozí voucherovou šablonu."); + } + + const remainingNulls = await tx.voucher.count({ where: { templateId: null } }); + if (remainingNulls !== 0) throw new Error(`Bootstrap skončil s ${remainingNulls} voucher řádky bez templateId.`); + if (fresh) { + await tx.voucherTemplateAuditLog.create({ + data: { templateId: template.id, templateKey: template.key, familyKey: template.familyKey, version: template.version, label: template.label, actorUserId: owner!.id, operation: "PUBLISH", metadata: { bootstrap: true, backfilledVouchers: backfilled.count } }, + }); + } + return { backfilledVouchers: backfilled.count, remainingNulls, legacyTemplateId: template.id, currentTemplateId: template.id, defaultTemplateId: settings.voucherDefaultTemplateId ?? template.id }; + }, { timeout: 30_000 }); + + if (repairedPreviewToCleanup) { + await deleteAsset(repairedPreviewToCleanup).catch(() => undefined); + } + return result; + } catch (error) { + for (const storagePath of stagedAssets) await deleteAsset(storagePath).catch(() => undefined); + if (createdTemplateId) await db.voucherTemplate.delete({ where: { id: createdTemplateId } }).catch(() => undefined); + throw error; + } +} + async function bootstrapOnce(dependencies: VoucherTemplateBootstrapDependencies) { + if (dependencies.readMaster) return bootstrapSingleTemplateCompatibility(dependencies); const db = dependencies.db ?? prisma; const readLegacyMaster = dependencies.readLegacyMaster ?? (() => readBundledMaster("classic-v1.pdf")); const readCurrentMaster = dependencies.readCurrentMaster ?? (() => readBundledMaster("classic-v2.pdf")); From 2303c14693f5de1912ef0164fc8f182a6c0ecfae Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:28:33 +0200 Subject: [PATCH 05/13] =?UTF-8?q?Odd=C4=9Bl=20produk=C4=8Dn=C3=AD=20bootst?= =?UTF-8?q?rap=20smoke=20od=20legacy=20CI=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/prepare-ci-voucher-fixtures.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/prepare-ci-voucher-fixtures.ts b/scripts/prepare-ci-voucher-fixtures.ts index 37b60b6a..18e607f7 100644 --- a/scripts/prepare-ci-voucher-fixtures.ts +++ b/scripts/prepare-ci-voucher-fixtures.ts @@ -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"; @@ -48,11 +51,13 @@ async function prepareCiVoucherFixtures() { }, }); - // CI používá stejnou bootstrap cestu jako production first rollout. - // Ověřuje tak historický classic-v1 i strict classic-v2 bez test-only override. - const result = await bootstrapVoucherTemplates(); + // Běžné integrační/E2E fixture zůstávají kompatibilní se scénáři, které + // historicky používají key classic-v1 jako strict testovací šablonu. + // Skutečný production first-rollout je samostatně ověřen smoke gate skriptem. + 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}; default=${result.defaultTemplateId}.`, + `CI voucher fixtures připraveny; backfill=${result.backfilledVouchers}; remainingNulls=${result.remainingNulls}.`, ); } From 77b9b4a97c136df9fe42d52605c9ac891ef4b887 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:29:28 +0200 Subject: [PATCH 06/13] =?UTF-8?q?P=C5=99idej=20production=20first-rollout?= =?UTF-8?q?=20smoke=20voucher=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/verify-voucher-first-rollout.ts | 179 ++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 scripts/verify-voucher-first-rollout.ts diff --git a/scripts/verify-voucher-first-rollout.ts b/scripts/verify-voucher-first-rollout.ts new file mode 100644 index 00000000..4861d25b --- /dev/null +++ b/scripts/verify-voucher-first-rollout.ts @@ -0,0 +1,179 @@ +import "dotenv/config"; + +import assert from "node:assert/strict"; + +import { AdminRole, VoucherStatus, VoucherType } from "@/generated/prisma/client"; +import { generateResolvedVoucherBatchPrintPdf } from "@/features/vouchers/lib/voucher-pdf"; +import { preflightFinalVoucherPrint } from "@/features/vouchers/lib/voucher-template-preflight"; +import { bootstrapVoucherTemplates } from "@/features/vouchers/lib/voucher-template-bootstrap"; +import { + requireVoucherTemplateById, + resolveVoucherTemplate, +} from "@/features/vouchers/lib/voucher-template-repository"; +import { + activateVoucherStockItem, + createVoucherPrintBatch, + receiveVoucherPrintBatch, +} from "@/features/vouchers/lib/voucher-stock"; +import { verifyVoucherPublic } from "@/features/vouchers/lib/voucher-validation"; +import { prisma } from "@/lib/prisma"; + +async function main() { + const owner = await prisma.adminUser.create({ + data: { + email: "voucher-first-rollout@example.test", + name: "Voucher first rollout", + role: AdminRole.OWNER, + isActive: true, + }, + }); + + await prisma.siteSettings.create({ + data: { + id: "site-settings", + salonName: "PP Studio", + addressLine: "Sadová 2", + city: "Zlín", + postalCode: "760 01", + phone: "+420 732 856 036", + contactEmail: "info@ppstudio.cz", + notificationAdminEmail: owner.email, + emailSenderName: "PP Studio", + emailSenderEmail: "info@ppstudio.cz", + }, + }); + + const legacyVoucher = await prisma.voucher.create({ + data: { + code: "PP-2026-LEGACY", + type: VoucherType.VALUE, + templateKey: "classic-v1", + templateId: null, + status: VoucherStatus.ACTIVE, + originalValueCzk: 1000, + remainingValueCzk: 1000, + validFrom: new Date("2026-01-01T00:00:00.000Z"), + validUntil: new Date("2027-12-31T23:59:59.999Z"), + issuedAt: new Date("2026-01-01T00:00:00.000Z"), + renderPolicy: null, + createdByUserId: owner.id, + }, + }); + + const bootstrap = await bootstrapVoucherTemplates(); + assert.equal(bootstrap.remainingNulls, 0); + assert.equal(bootstrap.backfilledVouchers, 1); + + const [legacyTemplate, currentTemplate, settings, backfilledVoucher] = await Promise.all([ + prisma.voucherTemplate.findUniqueOrThrow({ where: { key: "classic-v1" } }), + prisma.voucherTemplate.findUniqueOrThrow({ where: { key: "classic-v2" } }), + prisma.siteSettings.findUniqueOrThrow({ where: { id: "site-settings" } }), + prisma.voucher.findUniqueOrThrow({ where: { id: legacyVoucher.id } }), + ]); + + assert.equal(legacyTemplate.status, "INACTIVE"); + assert.equal(legacyTemplate.validationPolicy, null); + assert.equal(currentTemplate.status, "PUBLISHED"); + assert.equal(currentTemplate.validationPolicy, "STRICT_V1"); + assert.equal(settings.voucherDefaultTemplateId, currentTemplate.id); + assert.equal(backfilledVoucher.templateId, legacyTemplate.id); + assert.equal(backfilledVoucher.templateKey, "classic-v1"); + assert.equal(backfilledVoucher.renderPolicy, null); + + const category = await prisma.serviceCategory.create({ + data: { name: "Smoke", slug: "voucher-first-rollout-smoke" }, + }); + const service = await prisma.service.create({ + data: { + categoryId: category.id, + name: "Smoke služba", + publicName: "Smoke služba", + slug: "voucher-first-rollout-smoke-service", + durationMinutes: 60, + priceFromCzk: 1500, + isActive: true, + isPubliclyBookable: true, + }, + }); + + const batch = await createVoucherPrintBatch({ + templateKey: "classic-v2", + quantity: 2, + createdByUserId: owner.id, + now: new Date("2026-09-28T12:00:00.000Z"), + }); + const items = await prisma.voucherStockItem.findMany({ + where: { batchId: batch.id }, + orderBy: { sequenceNumber: "asc" }, + }); + assert.equal(items.length, 2); + + const resolvedTemplate = await resolveVoucherTemplate( + await requireVoucherTemplateById(currentTemplate.id), + ); + const stockPdf = await generateResolvedVoucherBatchPrintPdf( + { + batchNumber: batch.batchNumber, + items: items.map((item) => ({ code: item.code })), + }, + resolvedTemplate, + { requirePrepress: true }, + ); + const stockPreflight = await preflightFinalVoucherPrint(stockPdf, 2); + assert.deepEqual(stockPreflight.errors, []); + assert.equal(stockPreflight.geometryValid, true); + assert.equal(stockPreflight.pdfXMetadataValid, true); + assert.equal(stockPreflight.iccProfileValid, true); + + await receiveVoucherPrintBatch({ + batchId: batch.id, + actorUserId: owner.id, + now: new Date("2026-09-28T12:05:00.000Z"), + }); + + const valueActivation = await activateVoucherStockItem({ + code: items[0]!.code, + type: VoucherType.VALUE, + originalValueCzk: 2000, + actorUserId: owner.id, + validityMonths: 12, + now: new Date("2026-09-28T12:10:00.000Z"), + }); + assert.equal(valueActivation.kind, "activated"); + + const serviceActivation = await activateVoucherStockItem({ + code: items[1]!.code, + type: VoucherType.SERVICE, + serviceId: service.id, + actorUserId: owner.id, + validityMonths: 12, + now: new Date("2026-09-28T12:11:00.000Z"), + }); + assert.equal(serviceActivation.kind, "activated"); + + const [valueVerification, serviceVerification] = await Promise.all([ + verifyVoucherPublic({ code: items[0]!.code }, new Date("2026-09-29T12:00:00.000Z")), + verifyVoucherPublic({ code: items[1]!.code }, new Date("2026-09-29T12:00:00.000Z")), + ]); + assert.equal(valueVerification.ok, true); + assert.equal(serviceVerification.ok, true); + + const secondBootstrap = await bootstrapVoucherTemplates(); + assert.equal(secondBootstrap.remainingNulls, 0); + assert.equal(secondBootstrap.backfilledVouchers, 0); + assert.equal(secondBootstrap.defaultTemplateId, currentTemplate.id); + + console.info("Voucher production first-rollout smoke: PASS", { + legacyTemplate: legacyTemplate.key, + currentTemplate: currentTemplate.key, + batchNumber: batch.batchNumber, + pages: items.length, + }); +} + +main() + .catch((error) => { + console.error("Voucher production first-rollout smoke: FAIL", error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); From d488e103420fd3a1dfca20f7c9c2758bae85ea99 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:29:40 +0200 Subject: [PATCH 07/13] =?UTF-8?q?Oprav=20=C4=8Das=20ve=C5=99ejn=C3=A9ho=20?= =?UTF-8?q?ov=C4=9B=C5=99en=C3=AD=20ve=20smoke=20testu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/verify-voucher-first-rollout.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/verify-voucher-first-rollout.ts b/scripts/verify-voucher-first-rollout.ts index 4861d25b..f6383a36 100644 --- a/scripts/verify-voucher-first-rollout.ts +++ b/scripts/verify-voucher-first-rollout.ts @@ -152,8 +152,8 @@ async function main() { assert.equal(serviceActivation.kind, "activated"); const [valueVerification, serviceVerification] = await Promise.all([ - verifyVoucherPublic({ code: items[0]!.code }, new Date("2026-09-29T12:00:00.000Z")), - verifyVoucherPublic({ code: items[1]!.code }, new Date("2026-09-29T12:00:00.000Z")), + verifyVoucherPublic({ code: items[0]!.code, now: new Date("2026-09-29T12:00:00.000Z") }), + verifyVoucherPublic({ code: items[1]!.code, now: new Date("2026-09-29T12:00:00.000Z") }), ]); assert.equal(valueVerification.ok, true); assert.equal(serviceVerification.ok, true); From 34e9ac94f81f55fb63c74d1f43f7923546e1d327 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:29:49 +0200 Subject: [PATCH 08/13] =?UTF-8?q?P=C5=99idej=20voucher=20first-rollout=20C?= =?UTF-8?q?I=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 5740364d..a9c587b7 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,8 @@ "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: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" + "voucher:templates:prepare-classic-v2": "node --conditions=react-server --import tsx scripts/prepare-voucher-classic-v2.mjs", + "ci:verify-voucher-first-rollout": "node --conditions=react-server --import tsx scripts/verify-voucher-first-rollout.ts" }, "dependencies": { "@fontsource/noto-sans": "^5.3.0", From dfc9d2f28f0bc2719a70f36b1c45716afbc07d3b Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:30:48 +0200 Subject: [PATCH 09/13] =?UTF-8?q?Izoluj=20voucher=20first-rollout=20smoke?= =?UTF-8?q?=20do=20vlastn=C3=AD=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/verify-voucher-first-rollout.ts | 403 ++++++++++++++---------- 1 file changed, 239 insertions(+), 164 deletions(-) diff --git a/scripts/verify-voucher-first-rollout.ts b/scripts/verify-voucher-first-rollout.ts index f6383a36..48474754 100644 --- a/scripts/verify-voucher-first-rollout.ts +++ b/scripts/verify-voucher-first-rollout.ts @@ -1,179 +1,254 @@ import "dotenv/config"; import assert from "node:assert/strict"; +import { rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; -import { AdminRole, VoucherStatus, VoucherType } from "@/generated/prisma/client"; -import { generateResolvedVoucherBatchPrintPdf } from "@/features/vouchers/lib/voucher-pdf"; -import { preflightFinalVoucherPrint } from "@/features/vouchers/lib/voucher-template-preflight"; -import { bootstrapVoucherTemplates } from "@/features/vouchers/lib/voucher-template-bootstrap"; -import { - requireVoucherTemplateById, - resolveVoucherTemplate, -} from "@/features/vouchers/lib/voucher-template-repository"; -import { - activateVoucherStockItem, - createVoucherPrintBatch, - receiveVoucherPrintBatch, -} from "@/features/vouchers/lib/voucher-stock"; -import { verifyVoucherPublic } from "@/features/vouchers/lib/voucher-validation"; -import { prisma } from "@/lib/prisma"; +const SMOKE_DB_NAME = "ppstudio_voucher_bootstrap"; +const SMOKE_STORAGE = "/tmp/ppstudio-voucher-first-rollout"; -async function main() { - const owner = await prisma.adminUser.create({ - data: { - email: "voucher-first-rollout@example.test", - name: "Voucher first rollout", - role: AdminRole.OWNER, - isActive: true, - }, +function run(command: string, args: string[], env: NodeJS.ProcessEnv) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + env, + stdio: "inherit", }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} skončil s kódem ${result.status ?? "unknown"}.`); + } +} - await prisma.siteSettings.create({ - data: { - id: "site-settings", - salonName: "PP Studio", - addressLine: "Sadová 2", - city: "Zlín", - postalCode: "760 01", - phone: "+420 732 856 036", - contactEmail: "info@ppstudio.cz", - notificationAdminEmail: owner.email, - emailSenderName: "PP Studio", - emailSenderEmail: "info@ppstudio.cz", - }, - }); +function databaseUrls() { + const base = new URL( + process.env.DATABASE_URL + ?? "postgresql://postgres:postgres@127.0.0.1:5432/ppstudio?schema=public", + ); + const admin = new URL(base); + admin.pathname = "/postgres"; + admin.search = ""; + + const smoke = new URL(base); + smoke.pathname = `/${SMOKE_DB_NAME}`; + smoke.searchParams.set("schema", "public"); + return { admin: admin.toString(), smoke: smoke.toString() }; +} - const legacyVoucher = await prisma.voucher.create({ - data: { - code: "PP-2026-LEGACY", - type: VoucherType.VALUE, - templateKey: "classic-v1", - templateId: null, - status: VoucherStatus.ACTIVE, - originalValueCzk: 1000, - remainingValueCzk: 1000, - validFrom: new Date("2026-01-01T00:00:00.000Z"), - validUntil: new Date("2027-12-31T23:59:59.999Z"), - issuedAt: new Date("2026-01-01T00:00:00.000Z"), - renderPolicy: null, - createdByUserId: owner.id, - }, - }); +async function resetSmokeDatabase(adminUrl: string) { + const { Client } = await import("pg"); + const client = new Client({ connectionString: adminUrl }); + await client.connect(); + try { + await client.query(`DROP DATABASE IF EXISTS "${SMOKE_DB_NAME}" WITH (FORCE)`); + await client.query(`CREATE DATABASE "${SMOKE_DB_NAME}"`); + } finally { + await client.end(); + } +} - const bootstrap = await bootstrapVoucherTemplates(); - assert.equal(bootstrap.remainingNulls, 0); - assert.equal(bootstrap.backfilledVouchers, 1); +async function dropSmokeDatabase(adminUrl: string) { + const { Client } = await import("pg"); + const client = new Client({ connectionString: adminUrl }); + await client.connect(); + try { + await client.query(`DROP DATABASE IF EXISTS "${SMOKE_DB_NAME}" WITH (FORCE)`); + } finally { + await client.end(); + } +} - const [legacyTemplate, currentTemplate, settings, backfilledVoucher] = await Promise.all([ - prisma.voucherTemplate.findUniqueOrThrow({ where: { key: "classic-v1" } }), - prisma.voucherTemplate.findUniqueOrThrow({ where: { key: "classic-v2" } }), - prisma.siteSettings.findUniqueOrThrow({ where: { id: "site-settings" } }), - prisma.voucher.findUniqueOrThrow({ where: { id: legacyVoucher.id } }), +async function main() { + const { admin, smoke } = databaseUrls(); + await resetSmokeDatabase(admin); + await rm(SMOKE_STORAGE, { recursive: true, force: true }); + + process.env.DATABASE_URL = smoke; + process.env.MEDIA_STORAGE_ROOT = SMOKE_STORAGE; + process.env.NEXT_PUBLIC_APP_URL ??= "https://ppstudio.cz"; + process.env.ADMIN_SESSION_SECRET ??= "voucher-first-rollout-smoke-secret-32-characters"; + + const smokeEnv = { ...process.env, DATABASE_URL: smoke, MEDIA_STORAGE_ROOT: SMOKE_STORAGE }; + run("npx", ["prisma", "migrate", "deploy"], smokeEnv); + run("npx", ["prisma", "generate"], smokeEnv); + + const [ + { AdminRole, VoucherStatus, VoucherType }, + { prisma }, + { bootstrapVoucherTemplates }, + { createVoucherPrintBatch, receiveVoucherPrintBatch, activateVoucherStockItem }, + { requireVoucherTemplateById, resolveVoucherTemplate }, + { generateResolvedVoucherBatchPrintPdf }, + { preflightFinalVoucherPrint }, + { verifyVoucherPublic }, + ] = await Promise.all([ + import("@/generated/prisma/client"), + import("@/lib/prisma"), + import("@/features/vouchers/lib/voucher-template-bootstrap"), + import("@/features/vouchers/lib/voucher-stock"), + import("@/features/vouchers/lib/voucher-template-repository"), + import("@/features/vouchers/lib/voucher-pdf"), + import("@/features/vouchers/lib/voucher-template-preflight"), + import("@/features/vouchers/lib/voucher-validation"), ]); - assert.equal(legacyTemplate.status, "INACTIVE"); - assert.equal(legacyTemplate.validationPolicy, null); - assert.equal(currentTemplate.status, "PUBLISHED"); - assert.equal(currentTemplate.validationPolicy, "STRICT_V1"); - assert.equal(settings.voucherDefaultTemplateId, currentTemplate.id); - assert.equal(backfilledVoucher.templateId, legacyTemplate.id); - assert.equal(backfilledVoucher.templateKey, "classic-v1"); - assert.equal(backfilledVoucher.renderPolicy, null); - - const category = await prisma.serviceCategory.create({ - data: { name: "Smoke", slug: "voucher-first-rollout-smoke" }, - }); - const service = await prisma.service.create({ - data: { - categoryId: category.id, - name: "Smoke služba", - publicName: "Smoke služba", - slug: "voucher-first-rollout-smoke-service", - durationMinutes: 60, - priceFromCzk: 1500, - isActive: true, - isPubliclyBookable: true, - }, - }); - - const batch = await createVoucherPrintBatch({ - templateKey: "classic-v2", - quantity: 2, - createdByUserId: owner.id, - now: new Date("2026-09-28T12:00:00.000Z"), - }); - const items = await prisma.voucherStockItem.findMany({ - where: { batchId: batch.id }, - orderBy: { sequenceNumber: "asc" }, - }); - assert.equal(items.length, 2); - - const resolvedTemplate = await resolveVoucherTemplate( - await requireVoucherTemplateById(currentTemplate.id), - ); - const stockPdf = await generateResolvedVoucherBatchPrintPdf( - { + try { + const owner = await prisma.adminUser.create({ + data: { + email: "voucher-first-rollout@example.test", + name: "Voucher first rollout", + role: AdminRole.OWNER, + isActive: true, + }, + }); + + await prisma.siteSettings.create({ + data: { + id: "site-settings", + salonName: "PP Studio", + addressLine: "Sadová 2", + city: "Zlín", + postalCode: "760 01", + phone: "+420 732 856 036", + contactEmail: "info@ppstudio.cz", + notificationAdminEmail: owner.email, + emailSenderName: "PP Studio", + emailSenderEmail: "info@ppstudio.cz", + }, + }); + + const legacyVoucher = await prisma.voucher.create({ + data: { + code: "PP-2026-LEGACY", + type: VoucherType.VALUE, + templateKey: "classic-v1", + templateId: null, + status: VoucherStatus.ACTIVE, + originalValueCzk: 1000, + remainingValueCzk: 1000, + validFrom: new Date("2026-01-01T00:00:00.000Z"), + validUntil: new Date("2027-12-31T23:59:59.999Z"), + issuedAt: new Date("2026-01-01T00:00:00.000Z"), + renderPolicy: null, + createdByUserId: owner.id, + }, + }); + + const bootstrap = await bootstrapVoucherTemplates(); + assert.equal(bootstrap.remainingNulls, 0); + assert.equal(bootstrap.backfilledVouchers, 1); + + const [legacyTemplate, currentTemplate, settings, backfilledVoucher] = await Promise.all([ + prisma.voucherTemplate.findUniqueOrThrow({ where: { key: "classic-v1" } }), + prisma.voucherTemplate.findUniqueOrThrow({ where: { key: "classic-v2" } }), + prisma.siteSettings.findUniqueOrThrow({ where: { id: "site-settings" } }), + prisma.voucher.findUniqueOrThrow({ where: { id: legacyVoucher.id } }), + ]); + + assert.equal(legacyTemplate.status, "INACTIVE"); + assert.equal(legacyTemplate.validationPolicy, null); + assert.equal(currentTemplate.status, "PUBLISHED"); + assert.equal(currentTemplate.validationPolicy, "STRICT_V1"); + assert.equal(settings.voucherDefaultTemplateId, currentTemplate.id); + assert.equal(backfilledVoucher.templateId, legacyTemplate.id); + assert.equal(backfilledVoucher.templateKey, "classic-v1"); + assert.equal(backfilledVoucher.renderPolicy, null); + + const category = await prisma.serviceCategory.create({ + data: { name: "Smoke", slug: "voucher-first-rollout-smoke" }, + }); + const service = await prisma.service.create({ + data: { + categoryId: category.id, + name: "Smoke služba", + publicName: "Smoke služba", + slug: "voucher-first-rollout-smoke-service", + durationMinutes: 60, + priceFromCzk: 1500, + isActive: true, + isPubliclyBookable: true, + }, + }); + + const batch = await createVoucherPrintBatch({ + templateKey: "classic-v2", + quantity: 2, + createdByUserId: owner.id, + now: new Date("2026-09-28T12:00:00.000Z"), + }); + const items = await prisma.voucherStockItem.findMany({ + where: { batchId: batch.id }, + orderBy: { sequenceNumber: "asc" }, + }); + assert.equal(items.length, 2); + + const resolvedTemplate = await resolveVoucherTemplate( + await requireVoucherTemplateById(currentTemplate.id), + ); + const stockPdf = await generateResolvedVoucherBatchPrintPdf( + { + batchNumber: batch.batchNumber, + items: items.map((item) => ({ code: item.code })), + }, + resolvedTemplate, + { requirePrepress: true }, + ); + const stockPreflight = await preflightFinalVoucherPrint(stockPdf, 2); + assert.deepEqual(stockPreflight.errors, []); + assert.equal(stockPreflight.geometryValid, true); + assert.equal(stockPreflight.pdfXMetadataValid, true); + assert.equal(stockPreflight.iccProfileValid, true); + + await receiveVoucherPrintBatch({ + batchId: batch.id, + actorUserId: owner.id, + now: new Date("2026-09-28T12:05:00.000Z"), + }); + + const valueActivation = await activateVoucherStockItem({ + code: items[0]!.code, + type: VoucherType.VALUE, + originalValueCzk: 2000, + actorUserId: owner.id, + validityMonths: 12, + now: new Date("2026-09-28T12:10:00.000Z"), + }); + assert.equal(valueActivation.kind, "activated"); + + const serviceActivation = await activateVoucherStockItem({ + code: items[1]!.code, + type: VoucherType.SERVICE, + serviceId: service.id, + actorUserId: owner.id, + validityMonths: 12, + now: new Date("2026-09-28T12:11:00.000Z"), + }); + assert.equal(serviceActivation.kind, "activated"); + + const [valueVerification, serviceVerification] = await Promise.all([ + verifyVoucherPublic({ code: items[0]!.code, now: new Date("2026-09-29T12:00:00.000Z") }), + verifyVoucherPublic({ code: items[1]!.code, now: new Date("2026-09-29T12:00:00.000Z") }), + ]); + assert.equal(valueVerification.ok, true); + assert.equal(serviceVerification.ok, true); + + const secondBootstrap = await bootstrapVoucherTemplates(); + assert.equal(secondBootstrap.remainingNulls, 0); + assert.equal(secondBootstrap.backfilledVouchers, 0); + assert.equal(secondBootstrap.defaultTemplateId, currentTemplate.id); + + console.info("Voucher production first-rollout smoke: PASS", { + legacyTemplate: legacyTemplate.key, + currentTemplate: currentTemplate.key, batchNumber: batch.batchNumber, - items: items.map((item) => ({ code: item.code })), - }, - resolvedTemplate, - { requirePrepress: true }, - ); - const stockPreflight = await preflightFinalVoucherPrint(stockPdf, 2); - assert.deepEqual(stockPreflight.errors, []); - assert.equal(stockPreflight.geometryValid, true); - assert.equal(stockPreflight.pdfXMetadataValid, true); - assert.equal(stockPreflight.iccProfileValid, true); - - await receiveVoucherPrintBatch({ - batchId: batch.id, - actorUserId: owner.id, - now: new Date("2026-09-28T12:05:00.000Z"), - }); - - const valueActivation = await activateVoucherStockItem({ - code: items[0]!.code, - type: VoucherType.VALUE, - originalValueCzk: 2000, - actorUserId: owner.id, - validityMonths: 12, - now: new Date("2026-09-28T12:10:00.000Z"), - }); - assert.equal(valueActivation.kind, "activated"); - - const serviceActivation = await activateVoucherStockItem({ - code: items[1]!.code, - type: VoucherType.SERVICE, - serviceId: service.id, - actorUserId: owner.id, - validityMonths: 12, - now: new Date("2026-09-28T12:11:00.000Z"), - }); - assert.equal(serviceActivation.kind, "activated"); - - const [valueVerification, serviceVerification] = await Promise.all([ - verifyVoucherPublic({ code: items[0]!.code, now: new Date("2026-09-29T12:00:00.000Z") }), - verifyVoucherPublic({ code: items[1]!.code, now: new Date("2026-09-29T12:00:00.000Z") }), - ]); - assert.equal(valueVerification.ok, true); - assert.equal(serviceVerification.ok, true); - - const secondBootstrap = await bootstrapVoucherTemplates(); - assert.equal(secondBootstrap.remainingNulls, 0); - assert.equal(secondBootstrap.backfilledVouchers, 0); - assert.equal(secondBootstrap.defaultTemplateId, currentTemplate.id); - - console.info("Voucher production first-rollout smoke: PASS", { - legacyTemplate: legacyTemplate.key, - currentTemplate: currentTemplate.key, - batchNumber: batch.batchNumber, - pages: items.length, - }); + pages: items.length, + }); + } finally { + await prisma.$disconnect(); + await rm(SMOKE_STORAGE, { recursive: true, force: true }); + await dropSmokeDatabase(admin); + } } -main() - .catch((error) => { - console.error("Voucher production first-rollout smoke: FAIL", error); - process.exitCode = 1; - }) - .finally(() => prisma.$disconnect()); +main().catch((error) => { + console.error("Voucher production first-rollout smoke: FAIL", error); + process.exitCode = 1; +}); From 8e968a08fad0f88e14c2dd6cbbf04c9503013198 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:30:57 +0200 Subject: [PATCH 10/13] =?UTF-8?q?P=C5=99idej=20first-rollout=20voucher=20g?= =?UTF-8?q?ate=20do=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c251d686..37ff1d42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,10 @@ jobs: - name: Install dependencies run: npm ci + - name: Verify voucher production first rollout + timeout-minutes: 8 + run: npm run ci:verify-voucher-first-rollout + - name: Prepare database run: | npx prisma migrate deploy From 891e8a6dbb0ecf9c0f757d8da4e0a61a11676437 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:31:21 +0200 Subject: [PATCH 11/13] =?UTF-8?q?Popi=C5=A1=20production=20readiness=20opr?= =?UTF-8?q?av=20voucher=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d314aea..84f1a39b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Formát je inspirovaný Keep a Changelog. ### Opraveno +- Produkční first-rollout Voucher Template Manageru nyní odděluje historický `classic-v1` (immutable, neaktivní, bez strict markeru) od aktuálního `classic-v2` (publikovaný `STRICT_V1` PDF/X-4 master); staré vouchery se backfillují na původní vzhled a nový default míří na `classic-v2`. +- CI má samostatný izolovaný production-first-rollout smoke gate nad čistou PostgreSQL databází a skutečnými bundled mastery; ověřuje migrace, bootstrap, STOCK PDF preflight, převzetí série, VALUE/SERVICE aktivaci, veřejné ověření a idempotentní druhý bootstrap. +- Admin PWA E2E kontroluje statické assety sekvenčně místo tří souběžných requestů při startu Next serveru, čímž odstraňuje náhodný `socket hang up` bez oslabení kontrolovaných hlaviček a scope. + - Editor voucherů upozorní na chybu aktualizace náhledu a nezobrazuje zastaralý výsledek; testovací PDF respektuje vybraný typ voucheru a tažení i změna rozměrů drží prvky uvnitř ořezové oblasti. - Chyby validace a správy voucherových šablon se nyní zobrazí administrátorovi jako srozumitelné hlášení místo neodchycené chyby v prohlížeči. From 94560ce549d97cc88d2d91e4601879a33d3865be Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:31:37 +0200 Subject: [PATCH 12/13] Aktualizuj voucher first-rollout dokumentaci --- docs/DEPLOYMENT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4bc473b9..9087ff3c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -14,11 +14,11 @@ Rollout musí držet následující pořadí; nový web ani worker nesmí mezi m 2. Zálohuj PostgreSQL **i celý `MEDIA_STORAGE_ROOT`**; samotná databáze nestačí k obnově master PDF. 3. Zastav `ppstudio-web` a `ppstudio-email-worker` a přes `systemctl is-active` ověř, že oba writeři skutečně neběží. 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. +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. Při prvním rollout z legacy produkce bootstrap zachová původní `classic-v1` jako historickou neaktivní šablonu bez strict markeru, vytvoří a publikuje `classic-v2` s aktuální `STRICT_V1` validací, backfilluje historické vouchery na `classic-v1`, nastaví `classic-v2` jako default pro nové vydání a ověří, že počet řádků s `templateId IS NULL` je nula. Opakovaný běh je idempotentní a existující platný strict default nepřepisuje. 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). 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. +`classic-v2` master s oficiálním profilem ColorPoint/Fujifilm Revoria uncoated je current bootstrap asset pro čistý first rollout. Produkční bootstrap jej uloží do privátního storage, publikuje jako `VoucherTemplate` s klíčem `classic-v2` a nastaví jako default pouze tehdy, když dosud není jiný platný strict default (nebo legacy default míří na neissuable `classic-v1`). Historické vouchery `classic-v1` dál odkazují na původní historickou šablonu a jejich master se nepřepisuje. - 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í. From e5812bc7d11e697df93bb8899eda6e5ed7edc196 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Mon, 28 Sep 2026 18:31:47 +0200 Subject: [PATCH 13/13] Zdokumentuj dual-template voucher bootstrap --- docs/DEVELOPMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 90dc8403..d4cadadc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -31,7 +31,7 @@ Pro centralizovaný přehled hlavních route handler kontraktů používej i [`d ## Voucher Template Manager - `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. +- 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. Čistý rollout vytvoří historický neaktivní `classic-v1` pro reprint/backfill starých voucherů a publikovaný strict `classic-v2` pro nové vydání a tisk. - `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. 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.