task-p.5.1

This commit is contained in:
phaichayon
2026-06-30 09:21:34 +07:00
parent 4570495a77
commit e4573ac0c9
28 changed files with 8566 additions and 1875 deletions

View File

@@ -17,7 +17,7 @@
{ {
"templateName": "ALLA Quotation Standard", "templateName": "ALLA Quotation Standard",
"organizationId": "d5c46139-9af4-421b-9395-d83620c62f9b", "organizationId": "d5c46139-9af4-421b-9395-d83620c62f9b",
"version": "1.0", "version": "2.1",
"duplicateFieldNames": [], "duplicateFieldNames": [],
"typoFields": [], "typoFields": [],
"unmappedPlaceholders": [], "unmappedPlaceholders": [],

View File

@@ -4,7 +4,7 @@
"staticLabelsStatus": "PASS", "staticLabelsStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "QT2606-1122",
"templateName": "ALLA Quotation Standard", "templateName": "ALLA Quotation Standard",
"templateVersion": "1.0", "templateVersion": "2.1",
"findingCount": 168, "findingCount": 168,
"findings": [ "findings": [
{ {

View File

@@ -3,8 +3,8 @@
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "QT2606-1122",
"approvedSnapshotAvailable": true, "approvedSnapshotAvailable": true,
"approvedTemplateVersionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89", "approvedTemplateVersionId": "86f99414-4656-44a9-9937-8457794dc710",
"computedTemplateVersionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89", "computedTemplateVersionId": "86f99414-4656-44a9-9937-8457794dc710",
"inconsistentTemplateKeys": [], "inconsistentTemplateKeys": [],
"computedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269", "computedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269",
"storedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269", "storedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269",

View File

@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-06-29T08:14:00.088Z", "generatedAt": "2026-06-30T01:35:49.157Z",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "QT2606-1122",
"artifacts": { "artifacts": {

View File

@@ -1,6 +1,6 @@
# PDF Integrity Summary # PDF Integrity Summary
- Generated At: 2026-06-29T08:14:00.088Z - Generated At: 2026-06-30T01:35:49.157Z
- Overall Status: PASS - Overall Status: PASS
- Quotation Code: QT2606-1122 - Quotation Code: QT2606-1122

View File

@@ -1,12 +1,12 @@
{ {
"generatedAt": "2026-06-29T08:13:58.800Z", "generatedAt": "2026-06-30T01:35:48.376Z",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "QT2606-1122",
"expectedFixtureCode": "QT-H5-AUDIT", "expectedFixtureCode": "QT-H5-AUDIT",
"template": { "template": {
"templateName": "ALLA Quotation Standard", "templateName": "ALLA Quotation Standard",
"version": "1.0", "version": "2.1",
"versionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89", "versionId": "86f99414-4656-44a9-9937-8457794dc710",
"fieldCount": 44, "fieldCount": 44,
"previousVersion": "2.0", "previousVersion": "2.0",
"deletedFields": [ "deletedFields": [

View File

@@ -1,9 +1,9 @@
# PDF Audit Report # PDF Audit Report
- Generated At: 2026-06-29T08:13:58.800Z - Generated At: 2026-06-30T01:35:48.376Z
- Overall Status: PASS - Overall Status: PASS
- Quotation Code: QT2606-1122 - Quotation Code: QT2606-1122
- Template: ALLA Quotation Standard v1.0 - Template: ALLA Quotation Standard v2.1
- Previous Version: 2.0 - Previous Version: 2.0
## Metrics ## Metrics

View File

@@ -0,0 +1,181 @@
# Task P.5.1 Implementation Report
## Scope
This report documents implementation work completed for
[task-p.5.1.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.5.1.md).
Focus of this round:
- production hardening for document template management
- metadata separation path from `schemaJson`
- duplicate version workflow
- stronger template health validation
- improved compare UX
- regression-safe migration support
- test coverage for new hardening helpers
---
## Review Inputs
Reviewed before implementation:
- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md)
- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md)
- [docs/standards/architecture-rules.md](/C:/Users\\mtpphtaps\\Documents\\gitea\\alla-allaos-fullstack/docs/standards/architecture-rules.md)
- [docs/standards/task-catalog.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-catalog.md)
- [docs/implementation/task-p.5-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.5-implementation.md)
- [docs/implementation/task-p.5-verification-report.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.5-verification-report.md)
---
## Implementation Summary
### 1. Metadata separation foundation
Added dedicated `metadata_json` support for document template versions:
- [src/db/schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts)
- [drizzle/0006_lazy_shiver_man.sql](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0006_lazy_shiver_man.sql)
Migration behavior:
- add `metadata_json`
- backfill legacy `__templateManagement` from `schema_json` into `metadata_json`
- scrub embedded metadata from `schema_json`
Compatibility helper added in:
- [metadata.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/metadata.ts)
This helper now:
- reads dedicated metadata first
- falls back to legacy metadata inside `schemaJson`
- sanitizes exported schema so PDFMe JSON stays clean
### 2. Document template server hardening
Updated template server foundation in:
- [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts)
- [management-service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/management-service.ts)
Key changes:
- sanitize `schemaJson` on create and update
- store lifecycle metadata in `metadataJson`
- preserve legacy metadata compatibility on read
- duplicate version support with cloned mappings and table columns
- stronger validation output with structured health issues
- compare output expanded with:
- field/schema changes
- mapping difference payload
- validation state for both versions
- basic JSON diff summary
### 3. Duplicate-as-draft workflow
Added explicit duplicate action:
- [src/app/api/crm/settings/document-templates/versions/[id]/duplicate/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/duplicate/route.ts)
Client and mutation support updated in:
- [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/service.ts)
- [mutations.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/mutations.ts)
Behavior implemented:
- duplicate any source version into a new draft
- copy schema JSON
- copy mappings
- copy mapping columns
- clear active lifecycle state
- generate a unique draft/copy version label
### 4. Compare UX improvement
Rebuilt comparison dialog in:
- [template-version-management-panel.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-version-management-panel.tsx)
Compare UI now shows tabs for:
- metadata
- placeholders
- mappings
- schema
- health
This replaces the earlier toast-only compare result.
### 5. Validation and health output
Extended validation contract in:
- [types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/types.ts)
New validation output includes:
- `healthStatus`
- structured `issues`
- `orphanMappings`
- `unsupportedSchemaTypes`
- `sectionMarkerIssues`
- suggested next step
### 6. Audit utility relocation
Moved reusable audit helpers into runtime code space:
- [src/features/foundation/pdf-audit/server/shared.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-audit/server/shared.ts)
Script shim now re-exports from:
- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts)
This removes direct runtime imports from `scripts/`.
### 7. Automated tests added
Added focused tests for hardening helpers:
- [metadata.test.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/metadata.test.ts)
- [service.test.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.test.ts)
Covered:
- legacy metadata extraction
- schema sanitization
- metadata merge precedence
- duplicate version label generation
---
## Generated Verification Artifacts
Verification commands updated existing generated outputs:
- [docs/implementation/pdf-audit-report.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/pdf-audit-report.json)
- [docs/implementation/pdf-audit-report.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/pdf-audit-report.md)
- [artifacts/pdf-audit/summary.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/artifacts/pdf-audit/summary.json)
- [artifacts/pdf-audit/summary.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/artifacts/pdf-audit/summary.md)
These were not hand-edited as part of feature logic. They are generated by verification runs.
---
## Known Gaps After This Round
- Turbopack warning still persists during `npm run build`, even though runtime no longer imports from `scripts/` directly.
- Full route-level management tests were not added yet.
- Minimal browser/E2E verification was not added yet.
- `audit:pdf` still reports an existing active template schema drift for ALLA product template data in the current database state.
---
## Outcome
P.5.1 significantly improved the production-readiness of the document template management foundation, especially around metadata handling, draft duplication, health reporting, and compare usability, while keeping the PDF runtime path unchanged.

View File

@@ -0,0 +1,124 @@
# Task P.5.1 Verification Report
## Scope
This report verifies implementation work for
[task-p.5.1.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.5.1.md).
Verification focus:
- metadata separation compile safety
- duplicate workflow compile safety
- compare UI compile safety
- helper test execution
- migration execution
- build and audit regression checks
---
## Commands Run
```bash
npm exec tsc --noEmit
npm exec tsx --test src/features/foundation/document-template/server/metadata.test.ts
npm exec tsx --test src/features/foundation/document-template/server/service.test.ts
npm run db:generate
npm run db:migrate
npm run build
npm run audit:pdf
```
---
## Results
### TypeScript
- `npm exec tsc --noEmit` = PASS
### Added tests
- `metadata.test.ts` = PASS
- `service.test.ts` = PASS
### Migration flow
- `npm run db:generate` = PASS
- `npm run db:migrate` = PASS
### Build
- `npm run build` = PASS
Residual build status:
- build warning still present from Turbopack trace on
[shared.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-audit/server/shared.ts)
### PDF audit regression
- `npm run audit:pdf` = command PASS
- `audit:pdf` integrity summary = FAIL
Observed blocking audit issue:
- `ACTIVE_VERSION_SCHEMA_DRIFT`
- current report points to ALLA active product template version not matching its source JSON
Relevant generated artifact:
- [artifacts/pdf-audit/template-version-report.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/artifacts/pdf-audit/template-version-report.json)
---
## What Was Verified Successfully
- document template metadata can now be modeled through dedicated `metadata_json`
- legacy embedded metadata remains readable through compatibility helpers
- duplicate version label generation works
- duplicate route was compiled into the app route tree
- management panel compiles with:
- duplicate action
- richer compare dialog
- stronger validation display
- migration file generated and applied successfully
---
## What Remains Unverified or Incomplete
- dedicated route-handler tests for validate/publish/activate/rollback/archive/duplicate
- minimal browser/E2E checks for `/dashboard/crm/settings/templates`
- full Turbopack warning cleanup
- clean `audit:pdf` integrity summary for current seeded DB state
---
## Acceptance Status
Acceptance criteria status for this round:
- `schemaJson` clean-path implementation added = PASS
- compatibility handling for legacy metadata added = PASS
- duplicate as draft workflow added = PASS
- compare UI made substantially more usable = PASS
- stronger template health structure added = PASS
- helper-level automated tests added = PASS
- build remains PASS = PASS
- runtime code no longer imports `scripts/` directly = PASS
- Turbopack warning resolved = FAIL
- runtime audit remains fully green = FAIL
- route tests complete = FAIL
- minimal E2E workflow complete = FAIL
---
## Verification Conclusion
P.5.1 made meaningful hardening progress and is build-safe, migration-safe, and test-improved.
However, this round does not fully satisfy every acceptance target yet because:
- the Turbopack warning still remains
- route/E2E coverage is still incomplete
- current database verification still shows active template schema drift in `audit:pdf`

View File

@@ -0,0 +1,11 @@
ALTER TABLE "crm_document_template_versions" ADD COLUMN "metadata_json" jsonb;
--> statement-breakpoint
UPDATE "crm_document_template_versions"
SET
"metadata_json" = COALESCE("metadata_json", "schema_json" -> '__templateManagement'),
"schema_json" = CASE
WHEN "schema_json" ? '__templateManagement'
THEN "schema_json" - '__templateManagement'
ELSE "schema_json"
END
WHERE "schema_json" ? '__templateManagement';

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1782464526434, "when": 1782464526434,
"tag": "0005_furry_maelstrom", "tag": "0005_furry_maelstrom",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1782749787685,
"tag": "0006_lazy_shiver_man",
"breakpoints": true
} }
] ]
} }

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -47,11 +47,13 @@
"audit:pdf:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts", "audit:pdf:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts",
"audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts", "audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts",
"audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts", "audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts",
"audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts", "audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts",
"audit:pdf:visual": "npx tsx scripts/audit-pdf-visual-regression.ts", "audit:pdf:visual": "npx tsx scripts/audit-pdf-visual-regression.ts",
"audit:pdf:visual:baseline": "npx tsx scripts/audit-pdf-visual-regression.ts -- --write-baseline", "audit:pdf:visual:baseline": "npx tsx scripts/audit-pdf-visual-regression.ts -- --write-baseline",
"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity", "audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity",
"setup:db": "npm run db:setup" "setup:db": "npm run db:setup",
"pdf:reseed": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
"pdf:activate:product": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1069.0", "@aws-sdk/client-s3": "^3.1069.0",

422
plans/task-p.5.1.md Normal file
View File

@@ -0,0 +1,422 @@
# Task P.5.1 - Document Template Management Production Hardening
## Objective
Harden the Document Template Management foundation from Task P.5 so it is ready for production use and future extension.
This task focuses on technical debt, maintainability, validation, test coverage, and production-readiness.
Do NOT change PDF runtime behavior.
Do NOT change Product Item Engine behavior.
Do NOT introduce Document Assembly or PDF Merge.
Do NOT introduce Render Configuration UI.
---
# Background
Task P.5 introduced the Document Template Management foundation.
Implemented capabilities include:
* lifecycle operations
* validate
* publish
* activate
* rollback
* archive
* preview
* compare
* audit summary
* visual regression summary
* management UI panel
Known remaining risks:
* lifecycle metadata currently lives inside `schemaJson`
* compare UI is minimal
* duplicate version shortcut is not explicit
* management route regression tests are missing
* browser/E2E verification is missing
* Turbopack build warning exists due to filesystem tracing from audit utilities
---
# Scope
Included:
* metadata separation design and implementation
* migration-safe lifecycle metadata handling
* duplicate version workflow
* version comparison UX improvement
* template health validation
* management route tests
* minimal browser/E2E checks
* build warning cleanup
* management audit hardening
Excluded:
* PDF runtime refactor
* Product Item template changes
* Document Assembly
* SLA merge
* Render Policy UI
* Optional section configuration
* Document Library
---
# Phase 1 - Metadata Separation
## Problem
Task P.5 stores lifecycle metadata inside `schemaJson`.
This is acceptable for foundation delivery but not ideal long term.
`schemaJson` should remain PDFMe template JSON only.
## Goal
Separate management metadata from PDFMe schema JSON.
Preferred approach:
```text
crm_document_template_versions
schema_json = pure PDFMe JSON
metadata_json = lifecycle/management metadata
```
If a database migration is approved, add `metadata_json`.
If schema migration is not allowed in this task, implement a compatibility adapter that:
* reads legacy metadata from `schemaJson`
* writes new metadata into a dedicated safe location only after schema support exists
* prevents metadata from leaking into exported PDFMe JSON
## Required Metadata
Support:
* lifecycleStatus
* runtimeVersion
* templateVariant
* brand
* publishedBy
* publishedAt
* activatedBy
* activatedAt
* archivedBy
* archivedAt
* previousVersionId
* validationSummary
* auditSummary
* visualSummary
## Acceptance
* Exported template JSON contains only valid PDFMe JSON.
* Runtime continues reading valid `schemaJson`.
* Existing versions remain readable.
* No template version data loss.
---
# Phase 2 - Duplicate Version Workflow
Add an explicit duplicate action.
## Behavior
Duplicate should:
* clone an existing version
* create a new Draft version
* copy schema JSON
* copy mappings
* reset lifecycle metadata
* clear active/published state
* assign a new version label
Example:
```text
2.0 -> 2.0-copy-1
```
or
```text
2.0 -> 2.1-draft
```
## UI
Add button:
```text
Duplicate as Draft
```
## Acceptance
* User can duplicate any non-archived version.
* New version is Draft.
* Existing active version remains unchanged.
* Mappings are copied correctly.
---
# Phase 3 - Version Compare UX
Improve compare from backend-only result to user-readable UI.
## Compare Tabs
Show:
1. Metadata diff
2. Placeholder diff
3. Mapping diff
4. Field/schema diff
5. JSON diff
## Minimum UX
* side-by-side version selector
* highlight added fields
* highlight removed fields
* highlight changed mappings
* show validation status of both versions
## Acceptance
* User can compare any two versions of the same template.
* UI explains differences without opening raw JSON manually.
* Compare does not mutate data.
---
# Phase 4 - Template Health Validation
Add a stronger validation layer before publish.
## Validate
* JSON parse validity
* PDFMe schema validity
* duplicate field names
* unknown plugins
* unsupported schema types
* missing required placeholders
* orphan mappings
* unmapped placeholders
* section marker validity
* missing active-compatible page roles
* items_table validity for product templates
* topic template validity
* signature field validity
## Output
Return structured health result:
```ts
type TemplateHealthStatus = 'pass' | 'warning' | 'fail';
```
With:
* issues
* severity
* location
* suggested fix
## Acceptance
* Invalid templates cannot be published.
* Warnings are visible.
* Health result is shown in UI.
---
# Phase 5 - Management Route Tests
Add automated tests for management endpoints.
## Test Routes
* validate
* publish
* activate
* rollback
* archive
* preview
* compare
* duplicate
## Test Cases
* unauthorized request
* missing version
* invalid lifecycle transition
* publish invalid template
* activate unpublished version
* rollback from active version
* archive active version protection
* compare incompatible versions
## Acceptance
* Tests run in CI-friendly mode.
* Tests do not require manual DB cleanup.
* Existing `npm exec tsc --noEmit` remains PASS.
---
# Phase 6 - Minimal E2E Verification
Add minimal browser-level verification for:
```text
/dashboard/crm/settings/templates
```
## Verify
* page loads
* version list renders
* management panel renders
* validation action can be triggered
* preview action can be triggered
* compare dialog/page opens
* duplicate action creates draft
* archive confirmation appears
## Acceptance
* E2E covers the core management workflow.
* Tests can run against local seeded data.
---
# Phase 7 - Build Warning Cleanup
## Current Warning
Build passes but Turbopack reports filesystem tracing warning from:
```text
scripts/pdf-audit-utils.ts
```
## Goal
Remove server build dependency on script-only utilities.
## Recommended Fix
Move shared audit logic into:
```text
src/features/foundation/pdf-audit/server/
```
Keep script wrappers in:
```text
scripts/
```
Script files should import shared logic, not the other way around.
## Acceptance
* `npm run build` passes without the filesystem tracing warning.
* Runtime code does not import from `scripts/`.
* Scripts may import from runtime-safe shared modules.
---
# Phase 8 - Regression Verification
Run:
```bash
npm exec tsc --noEmit
npm run build
npm run audit:pdf
```
Also run any route tests and E2E tests added in this task.
Verify:
* legacy template still works
* product template still works
* PDF runtime remains unchanged
* visual regression baselines remain valid
* template management actions work
---
# Deliverables
* separated metadata handling
* duplicate version workflow
* compare UX improvement
* template health validation
* management route tests
* minimal E2E coverage
* build warning cleanup
* updated documentation
---
# Acceptance Criteria
* `schemaJson` remains clean PDFMe JSON.
* Lifecycle metadata no longer pollutes exported templates.
* Existing versions remain readable through compatibility handling.
* Duplicate as Draft works.
* Compare UI is usable.
* Template Health blocks invalid publish.
* Management route tests pass.
* Minimal E2E workflow passes.
* Turbopack warning is resolved or explicitly documented if unavoidable.
* Runtime audit remains PASS.
* Build remains PASS.
---
# Out of Scope
## P.5.2
* Document Library
* SLA PDF records
* Warranty PDF records
* Appendix records
## P.6
* PDF Merge
* SLA Assembly
* Warranty Assembly
* Final document package generation
## P.7
* Render Configuration
* Optional section UI
* User-selectable Product Table visibility
---
# Final Success Condition
Task P.5.1 is complete when Document Template Management is production-hardened, metadata is safely separated from PDFMe schema JSON, version operations are test-covered, comparison and validation are usable, and build/runtime regression checks remain fully green.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import { duplicateDocumentTemplateVersion } from '@/features/foundation/document-template/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(_request: Request, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateCreate,
});
const version = await duplicateDocumentTemplateVersion(
id,
organization.id,
session.user.id,
);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template_version',
entityId: version.id,
action: 'duplicate',
afterData: version,
});
return NextResponse.json({
success: true,
message: 'Document template version duplicated successfully',
version,
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable duplicate document template version' },
{ status: 500 },
);
}
}

View File

@@ -915,6 +915,7 @@ export const crmDocumentTemplateVersions = pgTable(
version: text('version').notNull(), version: text('version').notNull(),
filePath: text('file_path'), filePath: text('file_path'),
schemaJson: jsonb('schema_json').notNull(), schemaJson: jsonb('schema_json').notNull(),
metadataJson: jsonb('metadata_json'),
previewImageUrl: text('preview_image_url'), previewImageUrl: text('preview_image_url'),
isActive: boolean('is_active').default(true).notNull(), isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),

View File

@@ -14,10 +14,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
activateDocumentTemplateVersionMutation, activateDocumentTemplateVersionMutation,
archiveDocumentTemplateVersionMutation, archiveDocumentTemplateVersionMutation,
compareDocumentTemplateVersionsMutation, compareDocumentTemplateVersionsMutation,
duplicateDocumentTemplateVersionMutation,
previewDocumentTemplateVersionMutation, previewDocumentTemplateVersionMutation,
publishDocumentTemplateVersionMutation, publishDocumentTemplateVersionMutation,
rollbackDocumentTemplateVersionMutation, rollbackDocumentTemplateVersionMutation,
@@ -26,8 +28,10 @@ import {
import { documentTemplateVersionManagementQueryOptions } from '../queries'; import { documentTemplateVersionManagementQueryOptions } from '../queries';
import type { import type {
DocumentTemplateVersionAuditSummary, DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionCompareResponse,
DocumentTemplateVersionDetail, DocumentTemplateVersionDetail,
DocumentTemplateVersionValidationSummary, DocumentTemplateVersionValidationSummary,
DocumentTemplateVersionVisualSummary,
} from '../types'; } from '../types';
function getStatusBadgeVariant(status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN') { function getStatusBadgeVariant(status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN') {
@@ -66,12 +70,12 @@ function SummaryCard({
return ( return (
<div className='rounded-lg bg-muted/40 p-3'> <div className='rounded-lg bg-muted/40 p-3'>
<div className='text-muted-foreground text-xs'>{label}</div> <div className='text-muted-foreground text-xs'>{label}</div>
<div className='mt-1 text-sm font-medium'>{value}</div> <div className='mt-1 text-sm font-medium break-words'>{value}</div>
</div> </div>
); );
} }
function IssueList({ function StringList({
title, title,
items, items,
}: { }: {
@@ -96,28 +100,35 @@ function IssueList({
); );
} }
function RuntimeIssueList({ function ValidationIssues({
validation, validation,
}: { }: {
validation: DocumentTemplateVersionValidationSummary | null | undefined; validation: DocumentTemplateVersionValidationSummary | null | undefined;
}) { }) {
if (!validation?.runtimeIssues.length) { if (!validation?.issues.length) {
return null; return null;
} }
return ( return (
<div className='space-y-2 rounded-lg border p-3'> <div className='space-y-2 rounded-lg border p-3'>
<div className='text-sm font-medium'>Runtime Issues</div> <div className='text-sm font-medium'>Template Health Issues</div>
<div className='space-y-2'> <div className='space-y-2'>
{validation.runtimeIssues.map((issue) => ( {validation.issues.map((issue) => (
<div key={`${issue.code}-${issue.message}`} className='rounded-md bg-muted/40 p-2 text-sm'> <div
<div className='flex items-center gap-2'> key={`${issue.code}-${issue.location}-${issue.message}`}
className='rounded-md bg-muted/40 p-3 text-sm'
>
<div className='flex flex-wrap items-center gap-2'>
<Badge variant={issue.severity === 'error' ? 'destructive' : 'outline'}> <Badge variant={issue.severity === 'error' ? 'destructive' : 'outline'}>
{issue.severity} {issue.severity}
</Badge> </Badge>
<span className='font-medium'>{issue.code}</span> <span className='font-medium'>{issue.code}</span>
<span className='text-muted-foreground'>{issue.location}</span>
</div> </div>
<div className='text-muted-foreground mt-1'>{issue.message}</div> <div className='mt-1'>{issue.message}</div>
{issue.suggestedFix ? (
<div className='text-muted-foreground mt-1'>{issue.suggestedFix}</div>
) : null}
</div> </div>
))} ))}
</div> </div>
@@ -125,6 +136,45 @@ function RuntimeIssueList({
); );
} }
function AuditSummary({
audit,
visual,
}: {
audit: DocumentTemplateVersionAuditSummary | null;
visual: DocumentTemplateVersionVisualSummary | null | undefined;
}) {
if (!audit) {
return (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No audit summary available.
</div>
);
}
return (
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
label='Audit'
value={<Badge variant={getStatusBadgeVariant(audit.status)}>{audit.status}</Badge>}
/>
<SummaryCard
label='Visual Regression'
value={
<Badge variant={getStatusBadgeVariant(audit.visualRegressionStatus)}>
{audit.visualRegressionStatus}
</Badge>
}
/>
<SummaryCard label='Audited At' value={audit.auditedAt ?? 'Not available'} />
<SummaryCard label='Runtime Version' value={audit.runtimeVersion ?? 'Not set'} />
<SummaryCard label='Visual Generated' value={visual?.generatedAt ?? 'Not run'} />
<SummaryCard label='Changed Pages' value={visual?.changedPages ?? 0} />
<SummaryCard label='Layout Differences' value={visual?.layoutDifferences ?? 0} />
<SummaryCard label='Visual Status' value={visual?.status ?? 'NOT_RUN'} />
</div>
);
}
function CompareDialog({ function CompareDialog({
open, open,
onOpenChange, onOpenChange,
@@ -141,6 +191,10 @@ function CompareDialog({
const [rightVersionId, setRightVersionId] = React.useState( const [rightVersionId, setRightVersionId] = React.useState(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id, versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
); );
const [comparison, setComparison] = React.useState<
DocumentTemplateVersionCompareResponse['comparison'] | null
>(null);
const compareMutation = useMutation({ const compareMutation = useMutation({
...compareDocumentTemplateVersionsMutation, ...compareDocumentTemplateVersionsMutation,
onError: (error) => onError: (error) =>
@@ -149,6 +203,7 @@ function CompareDialog({
React.useEffect(() => { React.useEffect(() => {
if (open) { if (open) {
setComparison(null);
setRightVersionId( setRightVersionId(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id, versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
); );
@@ -161,29 +216,21 @@ function CompareDialog({
leftVersionId: currentVersion.id, leftVersionId: currentVersion.id,
rightVersionId, rightVersionId,
}); });
setComparison(result.comparison);
const metadataCount = result.comparison.metadataChanges.length;
const placeholderCount =
result.comparison.placeholderOnlyLeft.length +
result.comparison.placeholderOnlyRight.length;
const mappingCount = result.comparison.mappingDifferences.length;
toast.success(
`Compare complete: ${metadataCount} metadata, ${placeholderCount} placeholders, ${mappingCount} mappings`,
);
onOpenChange(false);
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-4xl'>
<DialogHeader> <DialogHeader>
<DialogTitle>Compare Versions</DialogTitle> <DialogTitle>Compare Versions</DialogTitle>
<DialogDescription> <DialogDescription>
Compare current version against another version in the same template family. Review metadata, placeholders, mappings, schema fields, and health state
without opening raw JSON manually.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className='grid gap-4'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'> <div className='space-y-2'>
<div className='text-sm font-medium'>Base Version</div> <div className='text-sm font-medium'>Base Version</div>
<div className='rounded-lg border px-3 py-2 text-sm'>{currentVersion.version}</div> <div className='rounded-lg border px-3 py-2 text-sm'>{currentVersion.version}</div>
@@ -205,6 +252,123 @@ function CompareDialog({
</select> </select>
</div> </div>
</div> </div>
{comparison ? (
<Tabs defaultValue='metadata' className='space-y-4'>
<TabsList className='flex flex-wrap'>
<TabsTrigger value='metadata'>Metadata</TabsTrigger>
<TabsTrigger value='placeholders'>Placeholders</TabsTrigger>
<TabsTrigger value='mappings'>Mappings</TabsTrigger>
<TabsTrigger value='schema'>Schema</TabsTrigger>
<TabsTrigger value='health'>Health</TabsTrigger>
</TabsList>
<TabsContent value='metadata' className='space-y-3'>
{comparison.metadataChanges.length ? (
comparison.metadataChanges.map((change) => (
<div key={change.field} className='rounded-lg border p-3 text-sm'>
<div className='font-medium'>{change.field}</div>
<div className='text-muted-foreground mt-1'>
Left: {String(change.left ?? 'null')}
</div>
<div className='text-muted-foreground'>
Right: {String(change.right ?? 'null')}
</div>
</div>
))
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No metadata differences detected.
</div>
)}
</TabsContent>
<TabsContent value='placeholders' className='space-y-3'>
<StringList
title='Only In Base Version'
items={comparison.placeholderOnlyLeft}
/>
<StringList
title='Only In Compared Version'
items={comparison.placeholderOnlyRight}
/>
{!comparison.placeholderOnlyLeft.length &&
!comparison.placeholderOnlyRight.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
Placeholder sets are identical.
</div>
) : null}
</TabsContent>
<TabsContent value='mappings' className='space-y-3'>
{comparison.mappingDifferences.length ? (
comparison.mappingDifferences.map((difference) => (
<div
key={`${difference.placeholderKey}-${difference.difference}`}
className='rounded-lg border p-3 text-sm'
>
<div className='font-medium'>{difference.placeholderKey}</div>
<div className='text-muted-foreground mt-1'>
{difference.difference}
</div>
</div>
))
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No mapping differences detected.
</div>
)}
</TabsContent>
<TabsContent value='schema' className='space-y-3'>
<StringList
title='Added Fields'
items={comparison.fieldSchemaChanges.addedFields}
/>
<StringList
title='Removed Fields'
items={comparison.fieldSchemaChanges.removedFields}
/>
{comparison.fieldSchemaChanges.changedFields.length ? (
<div className='space-y-2 rounded-lg border p-3'>
<div className='text-sm font-medium'>Changed Field Types</div>
{comparison.fieldSchemaChanges.changedFields.map((item) => (
<div key={item.field} className='text-muted-foreground text-sm'>
{item.field}: {item.difference}
</div>
))}
</div>
) : null}
{comparison.jsonDiff.length ? (
<div className='space-y-2 rounded-lg border p-3'>
<div className='text-sm font-medium'>JSON Differences</div>
{comparison.jsonDiff.map((item) => (
<div key={item.path} className='text-muted-foreground text-sm'>
{item.path}: {String(item.left ?? 'null')} {'->'}{' '}
{String(item.right ?? 'null')}
</div>
))}
</div>
) : null}
</TabsContent>
<TabsContent value='health' className='space-y-3'>
<div className='grid gap-3 md:grid-cols-2'>
<SummaryCard
label='Base Validation'
value={comparison.leftValidation.status}
/>
<SummaryCard
label='Compared Validation'
value={comparison.rightValidation.status}
/>
</div>
<ValidationIssues validation={comparison.leftValidation} />
<ValidationIssues validation={comparison.rightValidation} />
</TabsContent>
</Tabs>
) : null}
<DialogFooter> <DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)}>
Close Close
@@ -267,6 +431,12 @@ export function TemplateVersionManagementPanel({
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Archive failed'), toast.error(error instanceof Error ? error.message : 'Archive failed'),
}); });
const duplicateMutation = useMutation({
...duplicateDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Draft duplicate created'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Duplicate failed'),
});
const previewMutation = useMutation({ const previewMutation = useMutation({
...previewDocumentTemplateVersionMutation, ...previewDocumentTemplateVersionMutation,
onSuccess: (result) => { onSuccess: (result) => {
@@ -280,37 +450,7 @@ export function TemplateVersionManagementPanel({
const validation = managedVersion.validationSummary ?? null; const validation = managedVersion.validationSummary ?? null;
const audit = managedVersion.auditSummary ?? null; const audit = managedVersion.auditSummary ?? null;
const visual = managedVersion.visualSummary ?? null;
function renderAuditSummary(auditSummary: DocumentTemplateVersionAuditSummary | null) {
if (!auditSummary) {
return <div className='text-muted-foreground text-sm'>No audit summary available.</div>;
}
return (
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
label='Audit'
value={<Badge variant={getStatusBadgeVariant(auditSummary.status)}>{auditSummary.status}</Badge>}
/>
<SummaryCard
label='Visual Regression'
value={
<Badge variant={getStatusBadgeVariant(auditSummary.visualRegressionStatus)}>
{auditSummary.visualRegressionStatus}
</Badge>
}
/>
<SummaryCard
label='Audited At'
value={auditSummary.auditedAt ?? 'Not available'}
/>
<SummaryCard
label='Runtime Version'
value={auditSummary.runtimeVersion ?? 'Not set'}
/>
</div>
);
}
return ( return (
<> <>
@@ -332,6 +472,7 @@ export function TemplateVersionManagementPanel({
) : null} ) : null}
</div> </div>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Button <Button
variant='outline' variant='outline'
@@ -341,6 +482,17 @@ export function TemplateVersionManagementPanel({
<Icons.post className='mr-2 h-4 w-4' /> <Icons.post className='mr-2 h-4 w-4' />
Preview Preview
</Button> </Button>
<Button
variant='outline'
onClick={() =>
duplicateMutation.mutate({ templateId, versionId: version.id })
}
isLoading={duplicateMutation.isPending}
disabled={managedVersion.lifecycleStatus === 'archived'}
>
<Icons.add className='mr-2 h-4 w-4' />
Duplicate as Draft
</Button>
<Button variant='outline' onClick={() => setCompareOpen(true)}> <Button variant='outline' onClick={() => setCompareOpen(true)}>
<Icons.share className='mr-2 h-4 w-4' /> <Icons.share className='mr-2 h-4 w-4' />
Compare Compare
@@ -428,24 +580,34 @@ export function TemplateVersionManagementPanel({
value={validation.validatedAt ?? 'Not validated'} value={validation.validatedAt ?? 'Not validated'}
/> />
<SummaryCard <SummaryCard
label='Required Missing' label='Health Status'
value={validation.requiredFieldsMissing.length} value={validation.healthStatus}
/> />
<SummaryCard <SummaryCard
label='Unmapped Placeholders' label='Suggested Next Step'
value={validation.unmappedPlaceholders.length} value={validation.suggestedNextStep ?? '-'}
/> />
</div> </div>
<IssueList
<StringList
title='Required Fields Missing' title='Required Fields Missing'
items={validation.requiredFieldsMissing} items={validation.requiredFieldsMissing}
/> />
<IssueList title='Duplicate Fields' items={validation.duplicateFields} /> <StringList title='Duplicate Fields' items={validation.duplicateFields} />
<IssueList <StringList title='Orphan Mappings' items={validation.orphanMappings} />
<StringList
title='Unmapped Placeholders' title='Unmapped Placeholders'
items={validation.unmappedPlaceholders} items={validation.unmappedPlaceholders}
/> />
<RuntimeIssueList validation={validation} /> <StringList
title='Unsupported Schema Types'
items={validation.unsupportedSchemaTypes}
/>
<StringList
title='Section Marker Issues'
items={validation.sectionMarkerIssues}
/>
<ValidationIssues validation={validation} />
</div> </div>
) : ( ) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'> <div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
@@ -456,7 +618,7 @@ export function TemplateVersionManagementPanel({
<div className='space-y-3'> <div className='space-y-3'>
<div className='font-medium'>Audit Summary</div> <div className='font-medium'>Audit Summary</div>
{renderAuditSummary(audit)} <AuditSummary audit={audit} visual={visual} />
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ import {
createDocumentTemplateVersion, createDocumentTemplateVersion,
deleteDocumentTemplate, deleteDocumentTemplate,
deleteDocumentTemplateMapping, deleteDocumentTemplateMapping,
duplicateDocumentTemplateVersion,
previewDocumentTemplateVersion, previewDocumentTemplateVersion,
publishDocumentTemplateVersion, publishDocumentTemplateVersion,
rollbackDocumentTemplateVersion, rollbackDocumentTemplateVersion,
@@ -254,6 +255,25 @@ export const archiveDocumentTemplateVersionMutation = mutationOptions({
}, },
}); });
export const duplicateDocumentTemplateVersionMutation = mutationOptions({
mutationFn: ({
templateId,
versionId,
}: {
templateId: string;
versionId: string;
}) => duplicateDocumentTemplateVersion(versionId),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateTemplateLists(),
invalidateTemplateDetail(variables.templateId),
invalidateVersionManagement(variables.versionId),
]);
}
},
});
export const previewDocumentTemplateVersionMutation = mutationOptions({ export const previewDocumentTemplateVersionMutation = mutationOptions({
mutationFn: (versionId: string) => previewDocumentTemplateVersion(versionId), mutationFn: (versionId: string) => previewDocumentTemplateVersion(versionId),
}); });

View File

@@ -11,20 +11,6 @@ import { AuthError } from '@/lib/auth/session';
import { generatePdfFromTemplate } from '@/features/foundation/pdf-generator/server/service'; import { generatePdfFromTemplate } from '@/features/foundation/pdf-generator/server/service';
import { buildQuotationPdfRuntime } from '@/features/crm/quotations/document/server/quotation-pdf-runtime'; import { buildQuotationPdfRuntime } from '@/features/crm/quotations/document/server/quotation-pdf-runtime';
import type { QuotationDocumentData } from '@/features/crm/quotations/document/types'; import type { QuotationDocumentData } from '@/features/crm/quotations/document/types';
import {
getDocumentTemplate,
mapDocumentDataToTemplateInput,
resolveTemplateMappings,
} from './service';
import type {
DocumentTemplateDetail,
DocumentTemplateLifecycleStatus,
DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionCompareResponse,
DocumentTemplateVersionDetail,
DocumentTemplateVersionPreviewResponse,
DocumentTemplateVersionValidationSummary,
} from '../types';
import { import {
PDFME_STATIC_LABEL_FIELD_KEYS, PDFME_STATIC_LABEL_FIELD_KEYS,
buildAuditPayload, buildAuditPayload,
@@ -33,27 +19,30 @@ import {
getEffectiveMappedKeys, getEffectiveMappedKeys,
normalizeTemplateInputAliases, normalizeTemplateInputAliases,
summarizeIssues, summarizeIssues,
} from '../../../../../scripts/pdf-audit-utils'; } from '@/features/foundation/pdf-audit/server/shared';
import {
getDocumentTemplate,
mapDocumentDataToTemplateInput,
resolveTemplateMappings,
} from './service';
import {
CURRENT_TEMPLATE_RUNTIME_VERSION,
getManagementMetadata,
mergeManagementMetadata,
sanitizeTemplateSchemaJson,
} from './metadata';
import type {
DocumentTemplateDetail,
DocumentTemplateLifecycleStatus,
DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionCompareResponse,
DocumentTemplateVersionDetail,
DocumentTemplateVersionPreviewResponse,
DocumentTemplateVersionVisualSummary,
DocumentTemplateVersionValidationSummary,
} from '../types';
type VersionRow = typeof crmDocumentTemplateVersions.$inferSelect; type VersionRow = typeof crmDocumentTemplateVersions.$inferSelect;
type DocumentTemplateManagementMetadata = {
lifecycleStatus?: DocumentTemplateLifecycleStatus;
runtimeVersion?: string | null;
templateVariant?: string | null;
brand?: string | null;
publishedAt?: string | null;
publishedBy?: string | null;
activatedAt?: string | null;
activatedBy?: string | null;
archivedAt?: string | null;
archivedBy?: string | null;
previousVersionId?: string | null;
nextVersionId?: string | null;
validatedAt?: string | null;
};
const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5-template-management';
const REQUIRED_TEMPLATE_PLACEHOLDERS: string[] = [ const REQUIRED_TEMPLATE_PLACEHOLDERS: string[] = [
'customer_name', 'customer_name',
'quotation_code_data', 'quotation_code_data',
@@ -63,38 +52,14 @@ const REQUIRED_TEMPLATE_PLACEHOLDERS: string[] = [
'app3', 'app3',
] as const; ] as const;
function getSchemaRecord(schemaJson: unknown): Record<string, unknown> {
return schemaJson && typeof schemaJson === 'object'
? { ...(schemaJson as Record<string, unknown>) }
: {};
}
function getManagementMetadata(schemaJson: unknown): DocumentTemplateManagementMetadata {
const schema = getSchemaRecord(schemaJson);
const metadata = schema.__templateManagement;
return metadata && typeof metadata === 'object'
? { ...(metadata as DocumentTemplateManagementMetadata) }
: {};
}
function withManagementMetadata(
schemaJson: unknown,
metadata: DocumentTemplateManagementMetadata,
): unknown {
return {
...getSchemaRecord(schemaJson),
__templateManagement: {
...getManagementMetadata(schemaJson),
...metadata,
},
};
}
function inferLifecycleStatus(row: VersionRow): DocumentTemplateLifecycleStatus { function inferLifecycleStatus(row: VersionRow): DocumentTemplateLifecycleStatus {
if (row.isActive) { if (row.isActive) {
return 'active'; return 'active';
} }
return getManagementMetadata(row.schemaJson).lifecycleStatus ?? 'draft'; return getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
}).lifecycleStatus ?? 'draft';
} }
async function getVersionRow(versionId: string, organizationId: string) { async function getVersionRow(versionId: string, organizationId: string) {
@@ -136,7 +101,10 @@ function decorateVersionDetail(args: {
auditSummary: DocumentTemplateVersionAuditSummary | null; auditSummary: DocumentTemplateVersionAuditSummary | null;
validationSummary?: DocumentTemplateVersionValidationSummary | null; validationSummary?: DocumentTemplateVersionValidationSummary | null;
}): DocumentTemplateVersionDetail { }): DocumentTemplateVersionDetail {
const metadata = getManagementMetadata(args.version.schemaJson); const metadata = getManagementMetadata({
metadataJson: args.version.metadataJson,
schemaJson: args.version.schemaJson,
});
const siblingIds = args.siblings.map((item) => item.id); const siblingIds = args.siblings.map((item) => item.id);
const currentIndex = siblingIds.indexOf(args.version.id); const currentIndex = siblingIds.indexOf(args.version.id);
@@ -168,8 +136,9 @@ function decorateVersionDetail(args: {
(currentIndex >= 0 && currentIndex < siblingIds.length - 1 (currentIndex >= 0 && currentIndex < siblingIds.length - 1
? siblingIds[currentIndex + 1] ? siblingIds[currentIndex + 1]
: null), : null),
auditSummary: args.auditSummary, auditSummary: args.auditSummary ?? metadata.auditSummary ?? null,
validationSummary: args.validationSummary ?? null, validationSummary: args.validationSummary ?? metadata.validationSummary ?? null,
visualSummary: metadata.visualSummary ?? null,
}; };
} }
@@ -217,6 +186,40 @@ async function loadAuditSummary(
return auditSummary; return auditSummary;
} }
async function loadVisualSummary(): Promise<DocumentTemplateVersionVisualSummary | null> {
const visualReportPath = path.resolve(
process.cwd(),
'artifacts/pdf-visual/visual-regression-report.json',
);
try {
const visualReport = JSON.parse(await readFile(visualReportPath, 'utf8')) as {
generatedAt?: string;
overallStatus?: 'PASS' | 'WARNING' | 'FAIL';
comparisons?: Array<{ changedPages?: number; diffCount?: number }>;
};
const comparisons = Array.isArray(visualReport.comparisons)
? visualReport.comparisons
: [];
return {
status: visualReport.overallStatus ?? 'NOT_RUN',
generatedAt: visualReport.generatedAt ?? null,
changedPages: comparisons.reduce(
(sum, item) => sum + (item.changedPages ?? 0),
0,
),
layoutDifferences: comparisons.reduce(
(sum, item) => sum + (item.diffCount ?? 0),
0,
),
};
} catch {
return null;
}
}
async function buildFixtureDocumentData(): Promise<QuotationDocumentData> { async function buildFixtureDocumentData(): Promise<QuotationDocumentData> {
const sql = createSqlClient(); const sql = createSqlClient();
try { try {
@@ -252,10 +255,7 @@ export async function validateDocumentTemplateVersion(
versionId: string, versionId: string,
organizationId: string, organizationId: string,
): Promise<DocumentTemplateVersionValidationSummary> { ): Promise<DocumentTemplateVersionValidationSummary> {
const { template, version, siblings } = await getTemplateContext( const { template, version } = await getTemplateContext(versionId, organizationId);
versionId,
organizationId,
);
const fields = extractTemplateFields(version.schemaJson); const fields = extractTemplateFields(version.schemaJson);
const mappedKeys = getEffectiveMappedKeys(fields, version.mappings); const mappedKeys = getEffectiveMappedKeys(fields, version.mappings);
const schemaFieldNames = new Set(fields.map((field) => field.name).filter(Boolean)); const schemaFieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
@@ -285,6 +285,11 @@ export async function validateDocumentTemplateVersion(
.map(([name]) => name) .map(([name]) => name)
.sort(); .sort();
const orphanMappings = version.mappings
.filter((mapping) => !schemaFieldNames.has(mapping.placeholderKey))
.map((mapping) => mapping.placeholderKey)
.sort();
const unmappedPlaceholders = [...schemaFieldNames] const unmappedPlaceholders = [...schemaFieldNames]
.filter( .filter(
(fieldName) => (fieldName) =>
@@ -299,6 +304,30 @@ export async function validateDocumentTemplateVersion(
) )
.sort(); .sort();
const unsupportedSchemaTypes = fields
.filter(
(field) =>
![
'text',
'line',
'rectangle',
'ellipse',
'table',
'image',
'pdf',
].includes(field.type),
)
.map((field) => `${field.name}:${field.type}`)
.sort();
const unknownPlugins: string[] = [];
const sectionMarkerIssues: string[] = [];
if (hasProductSection && !schemaFieldNames.has('items_table')) {
sectionMarkerIssues.push(
'Product section marker exists but items_table placeholder is missing',
);
}
const documentData = await buildFixtureDocumentData(); const documentData = await buildFixtureDocumentData();
const templateInput = mapDocumentDataToTemplateInput( const templateInput = mapDocumentDataToTemplateInput(
documentData as unknown as Record<string, unknown>, documentData as unknown as Record<string, unknown>,
@@ -315,6 +344,74 @@ export async function validateDocumentTemplateVersion(
templateInput: normalizedTemplateInput, templateInput: normalizedTemplateInput,
}); });
const issues: DocumentTemplateVersionValidationSummary['issues'] = [];
for (const field of requiredFieldsMissing) {
issues.push({
code: 'missing_required_placeholder',
severity: 'error',
location: field,
message: `Required placeholder ${field} is missing`,
suggestedFix: 'Add the placeholder into the template schema.',
});
}
for (const field of duplicateFields) {
issues.push({
code: 'duplicate_field_name',
severity: 'error',
location: field,
message: `Field ${field} is duplicated in the template schema`,
suggestedFix: 'Rename or remove duplicated fields so each placeholder is unique.',
});
}
for (const field of orphanMappings) {
issues.push({
code: 'orphan_mapping',
severity: 'warning',
location: field,
message: `Mapping ${field} does not exist in the current schema`,
suggestedFix: 'Remove the mapping or restore the matching placeholder in the schema.',
});
}
for (const field of unmappedPlaceholders) {
issues.push({
code: 'unmapped_placeholder',
severity: 'warning',
location: field,
message: `Placeholder ${field} does not have a mapping`,
suggestedFix: 'Add a mapping or convert the field into a static designer label.',
});
}
for (const field of unsupportedSchemaTypes) {
issues.push({
code: 'unsupported_schema_type',
severity: 'error',
location: field,
message: `Unsupported schema field type detected: ${field}`,
suggestedFix: 'Use only supported PDFMe field types for runtime rendering.',
});
}
for (const issue of sectionMarkerIssues) {
issues.push({
code: 'section_marker_invalid',
severity: 'error',
location: 'items_table',
message: issue,
suggestedFix: 'Add items_table to the template and map it to quotation items.',
});
}
for (const issue of runtime.issues) {
issues.push({
code: issue.code,
severity: issue.severity,
location: 'runtime',
message: issue.message,
suggestedFix:
issue.severity === 'error'
? 'Resolve runtime compatibility before publish.'
: 'Review runtime warning and confirm it is acceptable.',
});
}
const statuses: Array<'PASS' | 'WARNING' | 'FAIL'> = []; const statuses: Array<'PASS' | 'WARNING' | 'FAIL'> = [];
if (requiredFieldsMissing.length) { if (requiredFieldsMissing.length) {
statuses.push('FAIL'); statuses.push('FAIL');
@@ -322,19 +419,40 @@ export async function validateDocumentTemplateVersion(
if (duplicateFields.length) { if (duplicateFields.length) {
statuses.push('FAIL'); statuses.push('FAIL');
} }
if (orphanMappings.length) {
statuses.push('WARNING');
}
if (unmappedPlaceholders.length) { if (unmappedPlaceholders.length) {
statuses.push('WARNING'); statuses.push('WARNING');
} }
if (unsupportedSchemaTypes.length || sectionMarkerIssues.length || unknownPlugins.length) {
statuses.push('FAIL');
}
statuses.push( statuses.push(
...runtime.issues.map((issue) => (issue.severity === 'error' ? 'FAIL' : 'WARNING')), ...runtime.issues.map((issue) => (issue.severity === 'error' ? 'FAIL' : 'WARNING')),
); );
const status = summarizeIssues(statuses.length ? statuses : ['PASS']);
return { return {
status: summarizeIssues(statuses.length ? statuses : ['PASS']), status,
validatedAt: new Date().toISOString(), validatedAt: new Date().toISOString(),
requiredFieldsMissing, requiredFieldsMissing,
duplicateFields, duplicateFields,
unmappedPlaceholders, unmappedPlaceholders,
orphanMappings,
unsupportedSchemaTypes,
unknownPlugins,
sectionMarkerIssues,
healthStatus:
status === 'FAIL' ? 'fail' : status === 'WARNING' ? 'warning' : 'pass',
issues,
suggestedNextStep:
status === 'FAIL'
? 'Fix blocking template issues before publish.'
: status === 'WARNING'
? 'Review warnings and confirm template intent.'
: 'Template is ready for publish.',
runtimeIssues: runtime.issues.map((issue) => ({ runtimeIssues: runtime.issues.map((issue) => ({
code: issue.code, code: issue.code,
severity: issue.severity, severity: issue.severity,
@@ -346,13 +464,15 @@ export async function validateDocumentTemplateVersion(
async function persistVersionManagementMetadata(args: { async function persistVersionManagementMetadata(args: {
versionId: string; versionId: string;
organizationId: string; organizationId: string;
schemaJson: unknown; schemaJson?: unknown;
metadataJson?: unknown;
isActive?: boolean; isActive?: boolean;
}) { }) {
const [updated] = await db const [updated] = await db
.update(crmDocumentTemplateVersions) .update(crmDocumentTemplateVersions)
.set({ .set({
schemaJson: args.schemaJson, ...(args.schemaJson === undefined ? {} : { schemaJson: args.schemaJson }),
...(args.metadataJson === undefined ? {} : { metadataJson: args.metadataJson }),
...(args.isActive === undefined ? {} : { isActive: args.isActive }), ...(args.isActive === undefined ? {} : { isActive: args.isActive }),
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -373,8 +493,12 @@ export async function getManagedDocumentTemplateVersion(
) { ) {
const { version, siblings } = await getTemplateContext(versionId, organizationId); const { version, siblings } = await getTemplateContext(versionId, organizationId);
const auditSummary = await loadAuditSummary(version); const auditSummary = await loadAuditSummary(version);
const visualSummary = await loadVisualSummary();
return decorateVersionDetail({ return decorateVersionDetail({
version, version: {
...version,
visualSummary,
},
siblings, siblings,
auditSummary, auditSummary,
}); });
@@ -388,18 +512,30 @@ export async function validateAndPersistDocumentTemplateVersion(args: {
args.versionId, args.versionId,
args.organizationId, args.organizationId,
); );
const { version } = await getTemplateContext(args.versionId, args.organizationId);
const row = await getVersionRow(args.versionId, args.organizationId); const row = await getVersionRow(args.versionId, args.organizationId);
const metadata = getManagementMetadata(row.schemaJson); const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
const auditSummary = await loadAuditSummary(version);
const visualSummary = await loadVisualSummary();
if (validation.status !== 'FAIL') { if (validation.status !== 'FAIL') {
await persistVersionManagementMetadata({ await persistVersionManagementMetadata({
versionId: args.versionId, versionId: args.versionId,
organizationId: args.organizationId, organizationId: args.organizationId,
schemaJson: withManagementMetadata(row.schemaJson, { metadataJson: mergeManagementMetadata({
...metadata, currentMetadataJson: row.metadataJson,
currentSchemaJson: row.schemaJson,
patch: {
lifecycleStatus: lifecycleStatus:
inferLifecycleStatus(row) === 'draft' ? 'validated' : inferLifecycleStatus(row), inferLifecycleStatus(row) === 'draft' ? 'validated' : inferLifecycleStatus(row),
validatedAt: validation.validatedAt, validatedAt: validation.validatedAt,
validationSummary: validation,
auditSummary,
visualSummary,
},
}), }),
}); });
} }
@@ -418,18 +554,25 @@ export async function publishDocumentTemplateVersion(args: {
} }
const row = await getVersionRow(args.versionId, args.organizationId); const row = await getVersionRow(args.versionId, args.organizationId);
const metadata = getManagementMetadata(row.schemaJson); const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
await persistVersionManagementMetadata({ await persistVersionManagementMetadata({
versionId: args.versionId, versionId: args.versionId,
organizationId: args.organizationId, organizationId: args.organizationId,
isActive: false, isActive: false,
schemaJson: withManagementMetadata(row.schemaJson, { metadataJson: mergeManagementMetadata({
...metadata, currentMetadataJson: row.metadataJson,
currentSchemaJson: row.schemaJson,
patch: {
lifecycleStatus: 'published', lifecycleStatus: 'published',
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION, runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
publishedAt: new Date().toISOString(), publishedAt: new Date().toISOString(),
publishedBy: args.userId, publishedBy: args.userId,
validatedAt: validation.validatedAt, validatedAt: validation.validatedAt,
validationSummary: validation,
},
}), }),
}); });
@@ -451,26 +594,39 @@ export async function activateDocumentTemplateVersion(args: {
const currentActive = siblings.find((item) => item.isActive && item.id !== row.id) ?? null; const currentActive = siblings.find((item) => item.isActive && item.id !== row.id) ?? null;
if (currentActive) { if (currentActive) {
const previousMetadata = getManagementMetadata(currentActive.schemaJson); const previousMetadata = getManagementMetadata({
metadataJson: currentActive.metadataJson,
schemaJson: currentActive.schemaJson,
});
await persistVersionManagementMetadata({ await persistVersionManagementMetadata({
versionId: currentActive.id, versionId: currentActive.id,
organizationId: args.organizationId, organizationId: args.organizationId,
isActive: false, isActive: false,
schemaJson: withManagementMetadata(currentActive.schemaJson, { metadataJson: mergeManagementMetadata({
currentMetadataJson: currentActive.metadataJson,
currentSchemaJson: currentActive.schemaJson,
patch: {
...previousMetadata, ...previousMetadata,
lifecycleStatus: 'published', lifecycleStatus: 'published',
nextVersionId: row.id, nextVersionId: row.id,
},
}), }),
}); });
} }
const metadata = getManagementMetadata(row.schemaJson); const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
await persistVersionManagementMetadata({ await persistVersionManagementMetadata({
versionId: row.id, versionId: row.id,
organizationId: args.organizationId, organizationId: args.organizationId,
isActive: true, isActive: true,
schemaJson: withManagementMetadata(row.schemaJson, { schemaJson: sanitizeTemplateSchemaJson(row.schemaJson),
...metadata, metadataJson: mergeManagementMetadata({
currentMetadataJson: row.metadataJson,
currentSchemaJson: row.schemaJson,
patch: {
lifecycleStatus: 'active', lifecycleStatus: 'active',
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION, runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
publishedAt: metadata.publishedAt ?? new Date().toISOString(), publishedAt: metadata.publishedAt ?? new Date().toISOString(),
@@ -479,6 +635,7 @@ export async function activateDocumentTemplateVersion(args: {
activatedBy: args.userId, activatedBy: args.userId,
previousVersionId: currentActive?.id ?? metadata.previousVersionId ?? null, previousVersionId: currentActive?.id ?? metadata.previousVersionId ?? null,
nextVersionId: null, nextVersionId: null,
},
}), }),
}); });
@@ -495,7 +652,10 @@ export async function rollbackDocumentTemplateVersion(args: {
throw new AuthError('Rollback is only allowed from the current active version.', 409); throw new AuthError('Rollback is only allowed from the current active version.', 409);
} }
const metadata = getManagementMetadata(row.schemaJson); const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
let targetVersionId = metadata.previousVersionId ?? null; let targetVersionId = metadata.previousVersionId ?? null;
if (!targetVersionId) { if (!targetVersionId) {
@@ -535,16 +695,22 @@ export async function archiveDocumentTemplateVersion(args: {
throw new AuthError('Active versions must be rolled back before archive.', 409); throw new AuthError('Active versions must be rolled back before archive.', 409);
} }
const metadata = getManagementMetadata(row.schemaJson); const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
await persistVersionManagementMetadata({ await persistVersionManagementMetadata({
versionId: row.id, versionId: row.id,
organizationId: args.organizationId, organizationId: args.organizationId,
isActive: false, isActive: false,
schemaJson: withManagementMetadata(row.schemaJson, { metadataJson: mergeManagementMetadata({
...metadata, currentMetadataJson: row.metadataJson,
currentSchemaJson: row.schemaJson,
patch: {
lifecycleStatus: 'archived', lifecycleStatus: 'archived',
archivedAt: new Date().toISOString(), archivedAt: new Date().toISOString(),
archivedBy: args.userId, archivedBy: args.userId,
},
}), }),
}); });
@@ -585,6 +751,10 @@ export async function compareDocumentTemplateVersions(args: {
const leftPlaceholderSet = new Set(left.mappings.map((item) => item.placeholderKey)); const leftPlaceholderSet = new Set(left.mappings.map((item) => item.placeholderKey));
const rightPlaceholderSet = new Set(right.mappings.map((item) => item.placeholderKey)); const rightPlaceholderSet = new Set(right.mappings.map((item) => item.placeholderKey));
const leftFields = extractTemplateFields(left.schemaJson);
const rightFields = extractTemplateFields(right.schemaJson);
const leftFieldNames = new Set(leftFields.map((field) => field.name).filter(Boolean));
const rightFieldNames = new Set(rightFields.map((field) => field.name).filter(Boolean));
const mappingDifferences = [ const mappingDifferences = [
...left.mappings.flatMap((mapping) => { ...left.mappings.flatMap((mapping) => {
@@ -614,12 +784,52 @@ export async function compareDocumentTemplateVersions(args: {
}); });
return currentSignature === otherSignature return currentSignature === otherSignature
? [] ? []
: [{ placeholderKey: mapping.placeholderKey, difference: 'Mapping contract changed' }]; : [{
placeholderKey: mapping.placeholderKey,
difference: 'Mapping contract changed',
leftValue: currentSignature,
rightValue: otherSignature,
}];
}), }),
]; ];
const leftValidation = await validateDocumentTemplateVersion(left.id, args.organizationId); const leftValidation = await validateDocumentTemplateVersion(left.id, args.organizationId);
const rightValidation = await validateDocumentTemplateVersion(right.id, args.organizationId); const rightValidation = await validateDocumentTemplateVersion(right.id, args.organizationId);
const jsonDiff: DocumentTemplateVersionCompareResponse['comparison']['jsonDiff'] = [];
const fieldSchemaChanges = {
addedFields: [...rightFieldNames].filter((field) => !leftFieldNames.has(field)).sort(),
removedFields: [...leftFieldNames].filter((field) => !rightFieldNames.has(field)).sort(),
changedFields: leftFields
.flatMap((leftField) => {
const rightField = rightFields.find((item) => item.name === leftField.name);
if (!rightField) {
return [];
}
if (leftField.type === rightField.type) {
return [];
}
return [
{
field: leftField.name,
difference: `${leftField.type} -> ${rightField.type}`,
},
];
})
.sort((a, b) => a.field.localeCompare(b.field)),
};
const leftSchema = sanitizeTemplateSchemaJson(left.schemaJson) as Record<string, unknown>;
const rightSchema = sanitizeTemplateSchemaJson(right.schemaJson) as Record<string, unknown>;
const leftPages = Array.isArray(leftSchema.schemas) ? leftSchema.schemas.length : 0;
const rightPages = Array.isArray(rightSchema.schemas) ? rightSchema.schemas.length : 0;
if (leftPages !== rightPages) {
jsonDiff.push({
path: 'schemas.length',
left: leftPages,
right: rightPages,
});
}
return { return {
leftVersionId: left.id, leftVersionId: left.id,
@@ -628,10 +838,14 @@ export async function compareDocumentTemplateVersions(args: {
placeholderOnlyLeft: [...leftPlaceholderSet].filter((item) => !rightPlaceholderSet.has(item)), placeholderOnlyLeft: [...leftPlaceholderSet].filter((item) => !rightPlaceholderSet.has(item)),
placeholderOnlyRight: [...rightPlaceholderSet].filter((item) => !leftPlaceholderSet.has(item)), placeholderOnlyRight: [...rightPlaceholderSet].filter((item) => !leftPlaceholderSet.has(item)),
mappingDifferences, mappingDifferences,
fieldSchemaChanges,
jsonDiff,
runtimeCompatibility: { runtimeCompatibility: {
leftStatus: leftValidation.status, leftStatus: leftValidation.status,
rightStatus: rightValidation.status, rightStatus: rightValidation.status,
}, },
leftValidation,
rightValidation,
}; };
} }

View File

@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getLegacyManagementMetadata,
getManagementMetadata,
mergeManagementMetadata,
sanitizeTemplateSchemaJson,
} from './metadata';
test('sanitizeTemplateSchemaJson removes embedded legacy management metadata', () => {
const schemaJson = {
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [],
__templateManagement: {
lifecycleStatus: 'published',
runtimeVersion: 'p5',
},
};
assert.deepEqual(sanitizeTemplateSchemaJson(schemaJson), {
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [],
});
});
test('getManagementMetadata falls back to legacy schema metadata when metadata_json is absent', () => {
const metadata = getManagementMetadata({
schemaJson: {
schemas: [],
__templateManagement: {
lifecycleStatus: 'validated',
runtimeVersion: 'legacy-runtime',
},
},
});
assert.equal(metadata.lifecycleStatus, 'validated');
assert.equal(metadata.runtimeVersion, 'legacy-runtime');
});
test('mergeManagementMetadata prefers dedicated metadata_json but preserves compatibility fallback', () => {
const metadata = mergeManagementMetadata({
currentMetadataJson: {
lifecycleStatus: 'draft',
runtimeVersion: 'p5.1',
},
currentSchemaJson: {
__templateManagement: {
lifecycleStatus: 'published',
runtimeVersion: 'legacy-runtime',
},
},
patch: {
lifecycleStatus: 'active',
publishedBy: 'user-1',
},
});
assert.equal(metadata.lifecycleStatus, 'active');
assert.equal(metadata.runtimeVersion, 'p5.1');
assert.equal(metadata.publishedBy, 'user-1');
});
test('getLegacyManagementMetadata returns empty object for clean PDFMe schema', () => {
assert.deepEqual(
getLegacyManagementMetadata({
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [],
}),
{},
);
});

View File

@@ -0,0 +1,86 @@
import type {
DocumentTemplateLifecycleStatus,
DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionValidationSummary,
} from '../types';
export const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5.1-template-management';
export interface DocumentTemplateVisualSummary {
status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN';
generatedAt: string | null;
changedPages: number;
layoutDifferences: number;
}
export interface DocumentTemplateManagementMetadata {
lifecycleStatus?: DocumentTemplateLifecycleStatus;
runtimeVersion?: string | null;
templateVariant?: string | null;
brand?: string | null;
publishedAt?: string | null;
publishedBy?: string | null;
activatedAt?: string | null;
activatedBy?: string | null;
archivedAt?: string | null;
archivedBy?: string | null;
previousVersionId?: string | null;
nextVersionId?: string | null;
validatedAt?: string | null;
validationSummary?: DocumentTemplateVersionValidationSummary | null;
auditSummary?: DocumentTemplateVersionAuditSummary | null;
visualSummary?: DocumentTemplateVisualSummary | null;
}
function getSchemaRecord(schemaJson: unknown): Record<string, unknown> {
return schemaJson && typeof schemaJson === 'object'
? { ...(schemaJson as Record<string, unknown>) }
: {};
}
export function getLegacyManagementMetadata(
schemaJson: unknown,
): DocumentTemplateManagementMetadata {
const schema = getSchemaRecord(schemaJson);
const metadata = schema.__templateManagement;
return metadata && typeof metadata === 'object'
? { ...(metadata as DocumentTemplateManagementMetadata) }
: {};
}
export function getManagementMetadata(args: {
metadataJson?: unknown;
schemaJson?: unknown;
}): DocumentTemplateManagementMetadata {
if (args.metadataJson && typeof args.metadataJson === 'object') {
return { ...(args.metadataJson as DocumentTemplateManagementMetadata) };
}
return getLegacyManagementMetadata(args.schemaJson);
}
export function sanitizeTemplateSchemaJson(schemaJson: unknown): unknown {
const schema = getSchemaRecord(schemaJson);
if (!('__templateManagement' in schema)) {
return schemaJson;
}
const { __templateManagement: _management, ...sanitized } = schema;
return sanitized;
}
export function mergeManagementMetadata(args: {
currentMetadataJson?: unknown;
currentSchemaJson?: unknown;
patch: DocumentTemplateManagementMetadata;
}): DocumentTemplateManagementMetadata {
return {
...getManagementMetadata({
metadataJson: args.currentMetadataJson,
schemaJson: args.currentSchemaJson,
}),
...args.patch,
};
}

View File

@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDuplicateVersionLabel } from './service';
test('createDuplicateVersionLabel creates draft label first', () => {
const label = createDuplicateVersionLabel(['1.0', '2.0'], '2.0');
assert.equal(label, '2.0-draft');
});
test('createDuplicateVersionLabel increments copy suffix when draft already exists', () => {
const label = createDuplicateVersionLabel(
['2.0', '2.0-draft', '2.0-copy-1'],
'2.0',
);
assert.equal(label, '2.0-copy-2');
});

View File

@@ -12,6 +12,12 @@ import {
formatPdfDate, formatPdfDate,
normalizePdfmeTable normalizePdfmeTable
} from '@/features/crm/quotations/document/server/pdfme-transforms'; } from '@/features/crm/quotations/document/server/pdfme-transforms';
import {
CURRENT_TEMPLATE_RUNTIME_VERSION,
getManagementMetadata,
mergeManagementMetadata,
sanitizeTemplateSchemaJson,
} from './metadata';
import type { import type {
DocumentTemplateDetail, DocumentTemplateDetail,
DocumentTemplateFilters, DocumentTemplateFilters,
@@ -30,54 +36,9 @@ import type {
ResolvedDocumentTemplate ResolvedDocumentTemplate
} from '../types'; } from '../types';
const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5-template-management';
type DocumentTemplateManagementMetadata = {
lifecycleStatus?: DocumentTemplateLifecycleStatus;
runtimeVersion?: string | null;
templateVariant?: string | null;
brand?: string | null;
publishedAt?: string | null;
publishedBy?: string | null;
activatedAt?: string | null;
activatedBy?: string | null;
archivedAt?: string | null;
archivedBy?: string | null;
previousVersionId?: string | null;
nextVersionId?: string | null;
validatedAt?: string | null;
};
function getTemplateSchemaRecord(schemaJson: unknown): Record<string, unknown> {
return schemaJson && typeof schemaJson === 'object'
? { ...(schemaJson as Record<string, unknown>) }
: {};
}
function getManagementMetadata(schemaJson: unknown): DocumentTemplateManagementMetadata {
const schema = getTemplateSchemaRecord(schemaJson);
const metadata = schema.__templateManagement;
return metadata && typeof metadata === 'object'
? { ...(metadata as DocumentTemplateManagementMetadata) }
: {};
}
function withManagementMetadata(
schemaJson: unknown,
metadata: DocumentTemplateManagementMetadata
): unknown {
return {
...getTemplateSchemaRecord(schemaJson),
__templateManagement: {
...getManagementMetadata(schemaJson),
...metadata
}
};
}
function inferLifecycleStatus(args: { function inferLifecycleStatus(args: {
isActive: boolean; isActive: boolean;
metadata: DocumentTemplateManagementMetadata; metadata: ReturnType<typeof getManagementMetadata>;
}): DocumentTemplateLifecycleStatus { }): DocumentTemplateLifecycleStatus {
if (args.isActive) { if (args.isActive) {
return 'active'; return 'active';
@@ -108,7 +69,10 @@ function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): Docum
function mapVersionRecord( function mapVersionRecord(
row: typeof crmDocumentTemplateVersions.$inferSelect row: typeof crmDocumentTemplateVersions.$inferSelect
): DocumentTemplateVersionRecord { ): DocumentTemplateVersionRecord {
const metadata = getManagementMetadata(row.schemaJson); const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
return { return {
id: row.id, id: row.id,
@@ -116,7 +80,8 @@ function mapVersionRecord(
templateId: row.templateId, templateId: row.templateId,
version: row.version, version: row.version,
filePath: row.filePath, filePath: row.filePath,
schemaJson: row.schemaJson, schemaJson: sanitizeTemplateSchemaJson(row.schemaJson),
metadataJson: row.metadataJson ?? null,
previewImageUrl: row.previewImageUrl, previewImageUrl: row.previewImageUrl,
isActive: row.isActive, isActive: row.isActive,
lifecycleStatus: inferLifecycleStatus({ lifecycleStatus: inferLifecycleStatus({
@@ -502,6 +467,15 @@ export async function createDocumentTemplateVersion(
payload: DocumentTemplateVersionMutationPayload payload: DocumentTemplateVersionMutationPayload
) { ) {
await assertTemplate(templateId, organizationId); await assertTemplate(templateId, organizationId);
const sanitizedSchemaJson = sanitizeTemplateSchemaJson(payload.schemaJson);
const metadataJson = mergeManagementMetadata({
patch: {
lifecycleStatus: payload.isActive ? 'active' : 'draft',
runtimeVersion: payload.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
templateVariant: payload.templateVariant ?? null,
brand: payload.brand ?? null,
},
});
const [created] = await db const [created] = await db
.insert(crmDocumentTemplateVersions) .insert(crmDocumentTemplateVersions)
.values({ .values({
@@ -510,9 +484,10 @@ export async function createDocumentTemplateVersion(
templateId, templateId,
version: payload.version.trim(), version: payload.version.trim(),
filePath: payload.filePath?.trim() || null, filePath: payload.filePath?.trim() || null,
schemaJson: payload.schemaJson, schemaJson: sanitizedSchemaJson,
metadataJson,
previewImageUrl: payload.previewImageUrl?.trim() || null, previewImageUrl: payload.previewImageUrl?.trim() || null,
isActive: payload.isActive ?? true, isActive: payload.isActive ?? false,
createdBy: userId createdBy: userId
}) })
.returning(); .returning();
@@ -526,12 +501,28 @@ export async function updateDocumentTemplateVersion(
payload: Partial<DocumentTemplateVersionMutationPayload> payload: Partial<DocumentTemplateVersionMutationPayload>
) { ) {
const current = await assertTemplateVersion(versionId, organizationId); const current = await assertTemplateVersion(versionId, organizationId);
const currentMetadata = getManagementMetadata({
metadataJson: current.metadataJson,
schemaJson: current.schemaJson,
});
const [updated] = await db const [updated] = await db
.update(crmDocumentTemplateVersions) .update(crmDocumentTemplateVersions)
.set({ .set({
version: payload.version?.trim() ?? current.version, version: payload.version?.trim() ?? current.version,
filePath: payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null, filePath: payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null,
schemaJson: payload.schemaJson ?? current.schemaJson, schemaJson:
payload.schemaJson === undefined
? sanitizeTemplateSchemaJson(current.schemaJson)
: sanitizeTemplateSchemaJson(payload.schemaJson),
metadataJson: mergeManagementMetadata({
currentMetadataJson: current.metadataJson,
currentSchemaJson: current.schemaJson,
patch: {
runtimeVersion: payload.runtimeVersion ?? currentMetadata.runtimeVersion ?? null,
templateVariant: payload.templateVariant ?? currentMetadata.templateVariant ?? null,
brand: payload.brand ?? currentMetadata.brand ?? null,
},
}),
previewImageUrl: previewImageUrl:
payload.previewImageUrl === undefined payload.previewImageUrl === undefined
? current.previewImageUrl ? current.previewImageUrl
@@ -545,6 +536,114 @@ export async function updateDocumentTemplateVersion(
return updated; return updated;
} }
export function createDuplicateVersionLabel(existingVersions: string[], baseVersion: string) {
const normalizedBase = baseVersion.trim();
const draftCandidate = `${normalizedBase}-draft`;
if (!existingVersions.includes(draftCandidate)) {
return draftCandidate;
}
let suffix = 1;
while (existingVersions.includes(`${normalizedBase}-copy-${suffix}`)) {
suffix += 1;
}
return `${normalizedBase}-copy-${suffix}`;
}
export async function duplicateDocumentTemplateVersion(
versionId: string,
organizationId: string,
userId: string,
) {
const sourceVersion = await assertTemplateVersion(versionId, organizationId);
const siblingVersions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.templateId, sourceVersion.templateId),
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt),
),
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
const metadata = getManagementMetadata({
metadataJson: sourceVersion.metadataJson,
schemaJson: sourceVersion.schemaJson,
});
const duplicateId = crypto.randomUUID();
const duplicateVersionLabel = createDuplicateVersionLabel(
siblingVersions.map((item) => item.version),
sourceVersion.version,
);
const [createdVersion] = await db
.insert(crmDocumentTemplateVersions)
.values({
id: duplicateId,
organizationId,
templateId: sourceVersion.templateId,
version: duplicateVersionLabel,
filePath: sourceVersion.filePath,
schemaJson: sanitizeTemplateSchemaJson(sourceVersion.schemaJson),
metadataJson: {
...metadata,
lifecycleStatus: 'draft',
publishedAt: null,
publishedBy: null,
activatedAt: null,
activatedBy: null,
archivedAt: null,
archivedBy: null,
previousVersionId: sourceVersion.id,
nextVersionId: null,
validationSummary: null,
auditSummary: null,
visualSummary: null,
},
previewImageUrl: sourceVersion.previewImageUrl,
isActive: false,
createdBy: userId,
})
.returning();
const mappings = await resolveTemplateMappings(sourceVersion.id, organizationId);
for (const mapping of mappings) {
const newMappingId = crypto.randomUUID();
await db.insert(crmDocumentTemplateMappings).values({
id: newMappingId,
organizationId,
templateVersionId: duplicateId,
placeholderKey: mapping.placeholderKey,
sourcePath: mapping.sourcePath,
dataType: mapping.dataType,
sheetName: mapping.sheetName,
defaultValue: mapping.defaultValue,
formatMask: mapping.formatMask,
sortOrder: mapping.sortOrder,
});
if (mapping.columns.length) {
await db.insert(crmDocumentTemplateTableColumns).values(
mapping.columns.map((column) => ({
id: crypto.randomUUID(),
organizationId,
mappingId: newMappingId,
columnName: column.columnName,
sourceField: column.sourceField,
columnLetter: column.columnLetter,
sortOrder: column.sortOrder,
formatMask: column.formatMask,
})),
);
}
}
return createdVersion;
}
export async function setDocumentTemplateVersionActive( export async function setDocumentTemplateVersionActive(
versionId: string, versionId: string,
organizationId: string, organizationId: string,

View File

@@ -145,6 +145,12 @@ export async function archiveDocumentTemplateVersion(id: string) {
}); });
} }
export async function duplicateDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/duplicate`, {
method: 'POST',
});
}
export async function previewDocumentTemplateVersion(id: string) { export async function previewDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionPreviewResponse>(`${BASE_PATH}/versions/${id}/preview`); return apiClient<DocumentTemplateVersionPreviewResponse>(`${BASE_PATH}/versions/${id}/preview`);
} }

View File

@@ -5,6 +5,8 @@ export type DocumentTemplateLifecycleStatus =
| 'active' | 'active'
| 'archived'; | 'archived';
export type TemplateHealthStatus = 'pass' | 'warning' | 'fail';
export interface DocumentTemplateRecord { export interface DocumentTemplateRecord {
id: string; id: string;
organizationId: string; organizationId: string;
@@ -28,6 +30,19 @@ export interface DocumentTemplateVersionValidationSummary {
requiredFieldsMissing: string[]; requiredFieldsMissing: string[];
duplicateFields: string[]; duplicateFields: string[];
unmappedPlaceholders: string[]; unmappedPlaceholders: string[];
orphanMappings: string[];
unsupportedSchemaTypes: string[];
unknownPlugins: string[];
sectionMarkerIssues: string[];
suggestedNextStep?: string | null;
issues: Array<{
code: string;
severity: 'warning' | 'error';
location: string;
message: string;
suggestedFix: string | null;
}>;
healthStatus: TemplateHealthStatus;
runtimeIssues: Array<{ runtimeIssues: Array<{
code: string; code: string;
severity: 'warning' | 'error'; severity: 'warning' | 'error';
@@ -43,6 +58,13 @@ export interface DocumentTemplateVersionAuditSummary {
visualRegressionAt: string | null; visualRegressionAt: string | null;
} }
export interface DocumentTemplateVersionVisualSummary {
status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN';
generatedAt: string | null;
changedPages: number;
layoutDifferences: number;
}
export interface DocumentTemplateVersionRecord { export interface DocumentTemplateVersionRecord {
id: string; id: string;
organizationId: string; organizationId: string;
@@ -50,6 +72,7 @@ export interface DocumentTemplateVersionRecord {
version: string; version: string;
filePath: string | null; filePath: string | null;
schemaJson: unknown; schemaJson: unknown;
metadataJson?: unknown;
previewImageUrl: string | null; previewImageUrl: string | null;
isActive: boolean; isActive: boolean;
lifecycleStatus?: DocumentTemplateLifecycleStatus; lifecycleStatus?: DocumentTemplateLifecycleStatus;
@@ -130,6 +153,7 @@ export interface DocumentTemplateVersionDetail
mappings: DocumentTemplateMappingWithColumns[]; mappings: DocumentTemplateMappingWithColumns[];
validationSummary?: DocumentTemplateVersionValidationSummary | null; validationSummary?: DocumentTemplateVersionValidationSummary | null;
auditSummary?: DocumentTemplateVersionAuditSummary | null; auditSummary?: DocumentTemplateVersionAuditSummary | null;
visualSummary?: DocumentTemplateVersionVisualSummary | null;
} }
export interface DocumentTemplateDetail extends DocumentTemplateRecord { export interface DocumentTemplateDetail extends DocumentTemplateRecord {
@@ -228,11 +252,28 @@ export interface DocumentTemplateVersionCompareResponse
mappingDifferences: Array<{ mappingDifferences: Array<{
placeholderKey: string; placeholderKey: string;
difference: string; difference: string;
leftValue?: string | null;
rightValue?: string | null;
}>;
fieldSchemaChanges: {
addedFields: string[];
removedFields: string[];
changedFields: Array<{
field: string;
difference: string;
}>;
};
jsonDiff: Array<{
path: string;
left: string | number | boolean | null;
right: string | number | boolean | null;
}>; }>;
runtimeCompatibility: { runtimeCompatibility: {
leftStatus: 'PASS' | 'WARNING' | 'FAIL'; leftStatus: 'PASS' | 'WARNING' | 'FAIL';
rightStatus: 'PASS' | 'WARNING' | 'FAIL'; rightStatus: 'PASS' | 'WARNING' | 'FAIL';
}; };
leftValidation: DocumentTemplateVersionValidationSummary;
rightValidation: DocumentTemplateVersionValidationSummary;
}; };
} }

File diff suppressed because it is too large Load Diff