task-p.5.1
This commit is contained in:
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
181
docs/implementation/task-p.5.1-implementation.md
Normal file
181
docs/implementation/task-p.5.1-implementation.md
Normal 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.
|
||||||
124
docs/implementation/task-p.5.1-verification-report.md
Normal file
124
docs/implementation/task-p.5.1-verification-report.md
Normal 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`
|
||||||
11
drizzle/0006_lazy_shiver_man.sql
Normal file
11
drizzle/0006_lazy_shiver_man.sql
Normal 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';
|
||||||
5163
drizzle/meta/0006_snapshot.json
Normal file
5163
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
2
next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -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
422
plans/task-p.5.1.md
Normal 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
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
lifecycleStatus: 'published',
|
currentSchemaJson: row.schemaJson,
|
||||||
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
|
patch: {
|
||||||
publishedAt: new Date().toISOString(),
|
lifecycleStatus: 'published',
|
||||||
publishedBy: args.userId,
|
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
|
||||||
validatedAt: validation.validatedAt,
|
publishedAt: new Date().toISOString(),
|
||||||
|
publishedBy: args.userId,
|
||||||
|
validatedAt: validation.validatedAt,
|
||||||
|
validationSummary: validation,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -451,34 +594,48 @@ 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({
|
||||||
...previousMetadata,
|
currentMetadataJson: currentActive.metadataJson,
|
||||||
lifecycleStatus: 'published',
|
currentSchemaJson: currentActive.schemaJson,
|
||||||
nextVersionId: row.id,
|
patch: {
|
||||||
|
...previousMetadata,
|
||||||
|
lifecycleStatus: 'published',
|
||||||
|
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({
|
||||||
lifecycleStatus: 'active',
|
currentMetadataJson: row.metadataJson,
|
||||||
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
|
currentSchemaJson: row.schemaJson,
|
||||||
publishedAt: metadata.publishedAt ?? new Date().toISOString(),
|
patch: {
|
||||||
publishedBy: metadata.publishedBy ?? args.userId,
|
lifecycleStatus: 'active',
|
||||||
activatedAt: new Date().toISOString(),
|
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
|
||||||
activatedBy: args.userId,
|
publishedAt: metadata.publishedAt ?? new Date().toISOString(),
|
||||||
previousVersionId: currentActive?.id ?? metadata.previousVersionId ?? null,
|
publishedBy: metadata.publishedBy ?? args.userId,
|
||||||
nextVersionId: null,
|
activatedAt: new Date().toISOString(),
|
||||||
|
activatedBy: args.userId,
|
||||||
|
previousVersionId: currentActive?.id ?? metadata.previousVersionId ?? 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,
|
||||||
lifecycleStatus: 'archived',
|
currentSchemaJson: row.schemaJson,
|
||||||
archivedAt: new Date().toISOString(),
|
patch: {
|
||||||
archivedBy: args.userId,
|
lifecycleStatus: 'archived',
|
||||||
|
archivedAt: new Date().toISOString(),
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
86
src/features/foundation/document-template/server/metadata.ts
Normal file
86
src/features/foundation/document-template/server/metadata.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1649
src/features/foundation/pdf-audit/server/shared.ts
Normal file
1649
src/features/foundation/pdf-audit/server/shared.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user