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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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

- 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.
Expand Down
4 changes: 2 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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í.

Expand Down
2 changes: 1 addition & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions scripts/prepare-ci-voucher-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ 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.
// 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(
Expand Down
254 changes: 254 additions & 0 deletions scripts/verify-voucher-first-rollout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import "dotenv/config";

import assert from "node:assert/strict";
import { rm } from "node:fs/promises";
import { spawnSync } from "node:child_process";

const SMOKE_DB_NAME = "ppstudio_voucher_bootstrap";
const SMOKE_STORAGE = "/tmp/ppstudio-voucher-first-rollout";

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"}.`);
}
}

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() };
}

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();
}
}

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();
}
}

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"),
]);

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,
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;
});
Loading
Loading