task-p.5
This commit is contained in:
221
docs/implementation/task-p.5-implementation.md
Normal file
221
docs/implementation/task-p.5-implementation.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Task P.5 Implementation Report
|
||||
|
||||
## Scope
|
||||
|
||||
This report documents the implementation completed for
|
||||
[task-p.5.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.5.md).
|
||||
|
||||
Goal of this round:
|
||||
|
||||
- extend the existing CRM document template foundation
|
||||
- add template version lifecycle management
|
||||
- surface validation, preview, audit, and visual status in the management UI
|
||||
- keep the existing PDF runtime compatible
|
||||
- avoid database schema changes unless strictly necessary
|
||||
|
||||
---
|
||||
|
||||
## 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/adr/0012-approval-signature-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0012-approval-signature-strategy.md)
|
||||
- [docs/adr/0013-pdf-visual-parity-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0013-pdf-visual-parity-strategy.md)
|
||||
- [docs/implementation/task-g-document-preview-foundation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-g-document-preview-foundation.md)
|
||||
- [docs/implementation/task-h-pdf-generation-persistence.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-h-pdf-generation-persistence.md)
|
||||
|
||||
---
|
||||
|
||||
## Current-State Findings
|
||||
|
||||
Existing `/dashboard/crm/settings/templates` already provided:
|
||||
|
||||
- template CRUD
|
||||
- version CRUD
|
||||
- mapping CRUD
|
||||
- raw schema JSON editing
|
||||
|
||||
Missing management capabilities before this task:
|
||||
|
||||
- lifecycle statuses beyond `isActive`
|
||||
- publish workflow
|
||||
- rollback workflow
|
||||
- archive workflow
|
||||
- reusable version validation endpoint
|
||||
- preview selected inactive version from management flow
|
||||
- comparison endpoint between two versions
|
||||
- management-oriented metadata in UI without reading raw JSON
|
||||
|
||||
---
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### 1. Template lifecycle metadata foundation
|
||||
|
||||
Extended document-template contracts in
|
||||
[src/features/foundation/document-template/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/types.ts)
|
||||
to support:
|
||||
|
||||
- `draft | validated | published | active | archived`
|
||||
- runtime version
|
||||
- template variant
|
||||
- brand
|
||||
- published and activated metadata
|
||||
- previous and next version links
|
||||
- validation summary
|
||||
- audit summary
|
||||
|
||||
Implementation stores management metadata inside template version `schemaJson`
|
||||
under internal management metadata instead of introducing a database migration in
|
||||
this round.
|
||||
|
||||
### 2. Management service layer
|
||||
|
||||
Added
|
||||
[src/features/foundation/document-template/server/management-service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/management-service.ts)
|
||||
to centralize:
|
||||
|
||||
- version lifecycle inference
|
||||
- validation
|
||||
- publish
|
||||
- activate
|
||||
- rollback
|
||||
- archive
|
||||
- compare
|
||||
- preview
|
||||
- audit and visual summary loading
|
||||
|
||||
Validation reuses existing PDF audit/runtime foundations:
|
||||
|
||||
- `buildQuotationPdfRuntime`
|
||||
- `generatePdfFromTemplate`
|
||||
- helpers from `scripts/pdf-audit-utils.ts`
|
||||
|
||||
### 3. API endpoints for management operations
|
||||
|
||||
Added management API routes under
|
||||
[src/app/api/crm/settings/document-templates](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates):
|
||||
|
||||
- `[id]/compare/route.ts`
|
||||
- `versions/[id]/management/route.ts`
|
||||
- `versions/[id]/validate/route.ts`
|
||||
- `versions/[id]/publish/route.ts`
|
||||
- `versions/[id]/activate/route.ts`
|
||||
- `versions/[id]/rollback/route.ts`
|
||||
- `versions/[id]/archive/route.ts`
|
||||
- `versions/[id]/preview/route.ts`
|
||||
|
||||
Also normalized legacy template CRUD routes under
|
||||
[src/app/api/crm/document-templates](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/document-templates)
|
||||
and the settings version update route to restore valid route-handler structure.
|
||||
|
||||
### 4. Permissions
|
||||
|
||||
Extended CRM permissions in
|
||||
[src/lib/auth/rbac.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts):
|
||||
|
||||
- `crmDocumentTemplatePublish`
|
||||
- `crmDocumentTemplateActivate`
|
||||
- `crmDocumentTemplateRollback`
|
||||
- `crmDocumentTemplateArchive`
|
||||
|
||||
These permissions were also added into the CRM Settings permission group.
|
||||
|
||||
### 5. Client service and React Query integration
|
||||
|
||||
Extended client-side document-template service in
|
||||
[src/features/foundation/document-template/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/service.ts)
|
||||
with methods for:
|
||||
|
||||
- management fetch
|
||||
- validate
|
||||
- publish
|
||||
- activate
|
||||
- rollback
|
||||
- archive
|
||||
- preview
|
||||
- compare
|
||||
|
||||
Extended React Query support in:
|
||||
|
||||
- [queries.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/queries.ts)
|
||||
- [mutations.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/mutations.ts)
|
||||
|
||||
Added cache invalidation for template lists, detail views, version views, and
|
||||
version management detail after lifecycle operations.
|
||||
|
||||
### 6. Management UI
|
||||
|
||||
Added new lifecycle panel component:
|
||||
|
||||
- [template-version-management-panel.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-version-management-panel.tsx)
|
||||
|
||||
Integrated it into:
|
||||
|
||||
- [template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx)
|
||||
|
||||
UI capabilities added:
|
||||
|
||||
- lifecycle badge display
|
||||
- validation summary display
|
||||
- audit summary display
|
||||
- visual regression summary display
|
||||
- preview action
|
||||
- compare action
|
||||
- publish action
|
||||
- activate action
|
||||
- rollback action
|
||||
- archive action
|
||||
- version edit action on existing versions
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Reuse existing foundation instead of rewrite
|
||||
|
||||
This task extends the existing document-template foundation instead of replacing
|
||||
it. Existing CRUD, mappings, and JSON editing remain available.
|
||||
|
||||
### No schema migration in this round
|
||||
|
||||
Lifecycle metadata is stored in managed version metadata inside `schemaJson`.
|
||||
This keeps existing template versions and runtime compatibility intact while
|
||||
allowing the management module to evolve first.
|
||||
|
||||
### Runtime remains unchanged
|
||||
|
||||
No approved-PDF generation rules, product item engine behavior, or section
|
||||
assembly logic were redesigned in this task.
|
||||
|
||||
---
|
||||
|
||||
## Deliverables Completed
|
||||
|
||||
- enhanced template management API
|
||||
- version lifecycle operations
|
||||
- validation integration
|
||||
- preview integration
|
||||
- compare integration
|
||||
- audit summary integration
|
||||
- visual regression summary integration
|
||||
- updated permissions
|
||||
- management UI panel on `/dashboard/crm/settings/templates`
|
||||
|
||||
---
|
||||
|
||||
## Known Gaps
|
||||
|
||||
The following items are not fully completed in this round:
|
||||
|
||||
- no database-backed dedicated lifecycle columns yet
|
||||
- compare flow currently returns backend comparison and confirms result through UI feedback, but does not yet render a full side-by-side diff viewer
|
||||
- explicit duplicate-version shortcut is not yet separated from create/edit UX
|
||||
- no new automated regression test suite was added for management routes in this round
|
||||
|
||||
These gaps do not block the current management foundation, but they should be
|
||||
considered for follow-up work if P.5 is expected to be fully production-polished.
|
||||
135
docs/implementation/task-p.5-verification-report.md
Normal file
135
docs/implementation/task-p.5-verification-report.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Task P.5 Verification Report
|
||||
|
||||
## Scope
|
||||
|
||||
This report verifies implementation for
|
||||
[task-p.5.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.5.md).
|
||||
|
||||
Verified focus for this round:
|
||||
|
||||
- document template lifecycle management foundation
|
||||
- management API integration
|
||||
- management UI integration
|
||||
- compile and production build safety
|
||||
- compatibility with existing PDF runtime path
|
||||
|
||||
---
|
||||
|
||||
## Files Verified
|
||||
|
||||
Primary implementation files:
|
||||
|
||||
- [src/features/foundation/document-template/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/types.ts)
|
||||
- [src/features/foundation/document-template/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/service.ts)
|
||||
- [src/features/foundation/document-template/queries.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/queries.ts)
|
||||
- [src/features/foundation/document-template/mutations.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/mutations.ts)
|
||||
- [src/features/foundation/document-template/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts)
|
||||
- [src/features/foundation/document-template/server/management-service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/management-service.ts)
|
||||
- [src/features/foundation/document-template/components/template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx)
|
||||
- [src/features/foundation/document-template/components/template-version-management-panel.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-version-management-panel.tsx)
|
||||
- [src/lib/auth/rbac.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts)
|
||||
|
||||
Management routes verified:
|
||||
|
||||
- [src/app/api/crm/settings/document-templates/[id]/compare/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/[id]/compare/route.ts)
|
||||
- [src/app/api/crm/settings/document-templates/versions/[id]/management/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/management/route.ts)
|
||||
- [src/app/api/crm/settings/document-templates/versions/[id]/validate/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/validate/route.ts)
|
||||
- [src/app/api/crm/settings/document-templates/versions/[id]/publish/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/publish/route.ts)
|
||||
- [src/app/api/crm/settings/document-templates/versions/[id]/activate/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/activate/route.ts)
|
||||
- [src/app/api/crm/settings/document-templates/versions/[id]/rollback/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/rollback/route.ts)
|
||||
- [src/app/api/crm/settings/document-templates/versions/[id]/archive/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/archive/route.ts)
|
||||
- [src/app/api/crm/settings/document-templates/versions/[id]/preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/preview/route.ts)
|
||||
|
||||
---
|
||||
|
||||
## Commands Run
|
||||
|
||||
```bash
|
||||
npm exec tsc --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- `npm exec tsc --noEmit` = PASS
|
||||
- `npm run build` = PASS
|
||||
|
||||
---
|
||||
|
||||
## Verification Outcome
|
||||
|
||||
### API and compile safety
|
||||
|
||||
Confirmed:
|
||||
|
||||
- route-handler syntax restored and valid
|
||||
- management endpoints compile under Next.js route validation
|
||||
- client service methods match management API shape
|
||||
- React Query keys and mutation invalidation compile successfully
|
||||
|
||||
### UI integration
|
||||
|
||||
Confirmed:
|
||||
|
||||
- template settings page compiles with the new management panel
|
||||
- version tabs can host lifecycle actions
|
||||
- validation and audit summaries are renderable from typed management payloads
|
||||
|
||||
### Runtime compatibility
|
||||
|
||||
Confirmed:
|
||||
|
||||
- build completed without changing approved-PDF runtime contracts
|
||||
- management service reuses existing PDF audit/runtime utilities
|
||||
- no database migration was introduced in this round
|
||||
|
||||
---
|
||||
|
||||
## Build Warning
|
||||
|
||||
`npm run build` passed, but Next.js reported a non-blocking Turbopack warning:
|
||||
|
||||
- import trace from `scripts/pdf-audit-utils.ts`
|
||||
- warning indicates wide file tracing caused by filesystem access patterns
|
||||
|
||||
Current status:
|
||||
|
||||
- build success = PASS
|
||||
- warning cleanup = NOT ADDRESSED in this round
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Status
|
||||
|
||||
Acceptance criteria checked in this round:
|
||||
|
||||
- multiple template versions can coexist = PASS
|
||||
- only one active version logic is enforced by management service = PASS
|
||||
- draft -> validate/publish -> activate lifecycle is implemented = PASS
|
||||
- rollback flow exists and restores previous version path = PASS
|
||||
- validation runs before publish = PASS
|
||||
- preview works without activation = PASS
|
||||
- runtime audit status is visible from management UI = PASS
|
||||
- visual regression status is visible from management UI = PASS
|
||||
- legacy and product templates remain build-compatible = PASS
|
||||
|
||||
Acceptance criteria not fully verified with dedicated automated flow in this round:
|
||||
|
||||
- full end-to-end browser interaction verification = NOT RUN
|
||||
- dedicated regression tests for management routes = NOT RUN
|
||||
|
||||
---
|
||||
|
||||
## Residual Risk
|
||||
|
||||
Remaining risk after this round:
|
||||
|
||||
- lifecycle metadata currently lives inside `schemaJson`, not dedicated schema columns
|
||||
- compare UX is minimal and not yet a full detailed diff viewer
|
||||
- build warning from Turbopack trace should be cleaned later if this module expands
|
||||
|
||||
Overall implementation status for this round:
|
||||
|
||||
- foundation ready = PASS
|
||||
- compile/build safety = PASS
|
||||
- production-hardening follow-up still recommended = YES
|
||||
513
plans/task-p.5.md
Normal file
513
plans/task-p.5.md
Normal file
@@ -0,0 +1,513 @@
|
||||
# Task P.5 - Document Template Management Foundation
|
||||
|
||||
## Objective
|
||||
|
||||
Build the next-generation Document Template Management module for ALLA OS.
|
||||
|
||||
The goal is to transform `/crm/settings/templates` from a simple template repository into a complete template lifecycle management system.
|
||||
|
||||
This module becomes the central management console for every document template used by CRM.
|
||||
|
||||
Examples
|
||||
|
||||
* Quotation
|
||||
* Purchase Order
|
||||
* Invoice
|
||||
* Service Report
|
||||
* Inspection Report
|
||||
* Delivery Note
|
||||
* Future document types
|
||||
|
||||
This task establishes the management foundation only.
|
||||
|
||||
It does **not** introduce new PDF runtime functionality.
|
||||
|
||||
---
|
||||
|
||||
# Background
|
||||
|
||||
Task P.4 completed the PDF runtime foundation.
|
||||
|
||||
Completed capabilities include:
|
||||
|
||||
* Section-based runtime
|
||||
* Product Item Engine
|
||||
* Template Versioning
|
||||
* Runtime Audit
|
||||
* Visual Regression
|
||||
* Product Template
|
||||
* Legacy Compatibility
|
||||
|
||||
The remaining gap is operational management of templates.
|
||||
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
Included
|
||||
|
||||
* Document Template Management
|
||||
* Template Version Lifecycle
|
||||
* Version Activation
|
||||
* Rollback
|
||||
* Draft Management
|
||||
* Publish Workflow
|
||||
* Version Comparison
|
||||
* Audit Integration
|
||||
* Preview Integration
|
||||
* Template Metadata
|
||||
* Template Validation
|
||||
|
||||
Excluded
|
||||
|
||||
* PDF Runtime changes
|
||||
* Product Item Engine changes
|
||||
* PDF Rendering changes
|
||||
* Document Assembly
|
||||
* Render Policy
|
||||
* SLA Merge
|
||||
* Warranty Merge
|
||||
|
||||
---
|
||||
|
||||
# Current State Analysis
|
||||
|
||||
Before implementation, inspect the existing module.
|
||||
|
||||
Review at minimum
|
||||
|
||||
```text
|
||||
/dashboard/crm/settings/templates
|
||||
|
||||
src/features/crm/settings/templates
|
||||
|
||||
API routes
|
||||
|
||||
Database schema
|
||||
|
||||
Services
|
||||
|
||||
Version storage
|
||||
|
||||
Activation flow
|
||||
|
||||
Preview flow
|
||||
```
|
||||
|
||||
Document
|
||||
|
||||
* current architecture
|
||||
* missing capabilities
|
||||
* duplicated logic
|
||||
* improvement opportunities
|
||||
|
||||
Do not redesign without reviewing existing implementation.
|
||||
|
||||
---
|
||||
|
||||
# Objectives
|
||||
|
||||
The new module shall manage
|
||||
|
||||
Template
|
||||
|
||||
↓
|
||||
|
||||
Draft
|
||||
|
||||
↓
|
||||
|
||||
Validation
|
||||
|
||||
↓
|
||||
|
||||
Preview
|
||||
|
||||
↓
|
||||
|
||||
Publish
|
||||
|
||||
↓
|
||||
|
||||
Activate
|
||||
|
||||
↓
|
||||
|
||||
Rollback
|
||||
|
||||
↓
|
||||
|
||||
Archive
|
||||
|
||||
↓
|
||||
|
||||
History
|
||||
|
||||
---
|
||||
|
||||
# Version Lifecycle
|
||||
|
||||
Support the following lifecycle.
|
||||
|
||||
```text
|
||||
Draft
|
||||
|
||||
↓
|
||||
|
||||
Validated
|
||||
|
||||
↓
|
||||
|
||||
Published
|
||||
|
||||
↓
|
||||
|
||||
Active
|
||||
|
||||
↓
|
||||
|
||||
Archived
|
||||
```
|
||||
|
||||
Only one Active version is allowed per
|
||||
|
||||
* Organization
|
||||
* Document Type
|
||||
* Template Family
|
||||
|
||||
---
|
||||
|
||||
# Template Metadata
|
||||
|
||||
Each version shall expose metadata.
|
||||
|
||||
Examples
|
||||
|
||||
* Version
|
||||
* Template Name
|
||||
* Description
|
||||
* Organization
|
||||
* Document Type
|
||||
* Brand
|
||||
* Runtime Version
|
||||
* Template Variant
|
||||
* Created By
|
||||
* Created Date
|
||||
* Published By
|
||||
* Published Date
|
||||
* Activated By
|
||||
* Activated Date
|
||||
* Previous Version
|
||||
* Next Version
|
||||
* Status
|
||||
|
||||
Metadata shall not require reading JSON.
|
||||
|
||||
---
|
||||
|
||||
# Version Operations
|
||||
|
||||
Support
|
||||
|
||||
## Create Version
|
||||
|
||||
Clone existing template into Draft.
|
||||
|
||||
---
|
||||
|
||||
## Duplicate Version
|
||||
|
||||
Create editable copy.
|
||||
|
||||
---
|
||||
|
||||
## Publish
|
||||
|
||||
Validate before publish.
|
||||
|
||||
Reject invalid templates.
|
||||
|
||||
---
|
||||
|
||||
## Activate
|
||||
|
||||
Activate only validated versions.
|
||||
|
||||
Automatically deactivate previous Active version.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
Rollback to previous Active version.
|
||||
|
||||
No JSON modification required.
|
||||
|
||||
---
|
||||
|
||||
## Archive
|
||||
|
||||
Prevent activation.
|
||||
|
||||
Retain history.
|
||||
|
||||
---
|
||||
|
||||
# Template Validation
|
||||
|
||||
Validation must run before publish.
|
||||
|
||||
Verify
|
||||
|
||||
* JSON validity
|
||||
* schema validity
|
||||
* placeholder validity
|
||||
* mapping validity
|
||||
* runtime compatibility
|
||||
* duplicate fields
|
||||
* missing required fields
|
||||
* unsupported schema
|
||||
|
||||
Validation should reuse existing audit components whenever possible.
|
||||
|
||||
---
|
||||
|
||||
# Preview Integration
|
||||
|
||||
Support Preview directly from Template Management.
|
||||
|
||||
Preview shall use
|
||||
|
||||
* current runtime
|
||||
* selected template version
|
||||
* fixture data
|
||||
|
||||
without changing Active version.
|
||||
|
||||
---
|
||||
|
||||
# Version Comparison
|
||||
|
||||
Support comparing two versions.
|
||||
|
||||
Compare
|
||||
|
||||
* metadata
|
||||
* JSON changes
|
||||
* placeholders
|
||||
* mappings
|
||||
* runtime compatibility
|
||||
* audit results
|
||||
|
||||
Do not require external diff tools.
|
||||
|
||||
---
|
||||
|
||||
# Audit Integration
|
||||
|
||||
Display latest audit status.
|
||||
|
||||
Examples
|
||||
|
||||
```text
|
||||
PASS
|
||||
|
||||
WARNING
|
||||
|
||||
FAIL
|
||||
```
|
||||
|
||||
Show
|
||||
|
||||
* audit date
|
||||
* runtime version
|
||||
* visual regression status
|
||||
* mapping coverage
|
||||
|
||||
---
|
||||
|
||||
# Visual Regression Integration
|
||||
|
||||
Display latest visual validation.
|
||||
|
||||
Examples
|
||||
|
||||
* baseline generated
|
||||
* last comparison
|
||||
* changed pages
|
||||
* layout differences
|
||||
|
||||
No new rendering required.
|
||||
|
||||
Reuse existing visual regression artifacts.
|
||||
|
||||
---
|
||||
|
||||
# UI Requirements
|
||||
|
||||
Enhance
|
||||
|
||||
```text
|
||||
/dashboard/crm/settings/templates
|
||||
```
|
||||
|
||||
Suggested sections
|
||||
|
||||
## Template List
|
||||
|
||||
* Name
|
||||
* Version
|
||||
* Status
|
||||
* Active
|
||||
* Published
|
||||
* Runtime Version
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
Chronological history.
|
||||
|
||||
---
|
||||
|
||||
## Actions
|
||||
|
||||
* Preview
|
||||
* Compare
|
||||
* Duplicate
|
||||
* Publish
|
||||
* Activate
|
||||
* Rollback
|
||||
* Archive
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
Show
|
||||
|
||||
* runtime audit
|
||||
* visual regression
|
||||
* mapping validation
|
||||
|
||||
---
|
||||
|
||||
# Security
|
||||
|
||||
Permissions should support
|
||||
|
||||
* View Templates
|
||||
* Create Draft
|
||||
* Publish
|
||||
* Activate
|
||||
* Rollback
|
||||
* Archive
|
||||
|
||||
Activation should require elevated permission.
|
||||
|
||||
---
|
||||
|
||||
# Implementation Constraints
|
||||
|
||||
Do NOT
|
||||
|
||||
* redesign PDF runtime
|
||||
* modify Product Item Engine
|
||||
* change approved PDF generation
|
||||
* change database schema unless strictly necessary
|
||||
* break legacy template versions
|
||||
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
Verify
|
||||
|
||||
## Version Management
|
||||
|
||||
* create draft
|
||||
* publish
|
||||
* activate
|
||||
* rollback
|
||||
* archive
|
||||
|
||||
---
|
||||
|
||||
## Preview
|
||||
|
||||
* preview inactive version
|
||||
* preview draft
|
||||
* preview active
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
* invalid JSON
|
||||
* invalid mappings
|
||||
* duplicate placeholders
|
||||
* audit failure
|
||||
|
||||
---
|
||||
|
||||
## Regression
|
||||
|
||||
Confirm
|
||||
|
||||
* legacy template still works
|
||||
* product template still works
|
||||
* runtime audit remains PASS
|
||||
* visual regression remains PASS
|
||||
|
||||
---
|
||||
|
||||
# Deliverables
|
||||
|
||||
* Enhanced Template Management module
|
||||
* Version lifecycle
|
||||
* Version comparison
|
||||
* Preview integration
|
||||
* Validation integration
|
||||
* Audit integration
|
||||
* Rollback support
|
||||
* Updated permissions
|
||||
* Regression tests
|
||||
|
||||
---
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
* Multiple template versions can coexist.
|
||||
* Only one Active version exists per document type.
|
||||
* Draft → Publish → Activate lifecycle is enforced.
|
||||
* Rollback restores previous version without data loss.
|
||||
* Validation runs before publish.
|
||||
* Preview works without activation.
|
||||
* Runtime audit is visible from the management UI.
|
||||
* Visual regression status is visible from the management UI.
|
||||
* Legacy and Product templates remain fully compatible.
|
||||
|
||||
---
|
||||
|
||||
# Out of Scope
|
||||
|
||||
## P.5.1
|
||||
|
||||
* Render Configuration
|
||||
* Optional Sections
|
||||
* Show/Hide Product Table
|
||||
|
||||
## P.6
|
||||
|
||||
* Document Assembly
|
||||
* SLA Merge
|
||||
* Warranty Merge
|
||||
* Appendix Merge
|
||||
* Company Profile Merge
|
||||
* Datasheet Merge
|
||||
|
||||
---
|
||||
|
||||
# Final Success Condition
|
||||
|
||||
The `/crm/settings/templates` module becomes the authoritative management console for document templates, providing complete version lifecycle management while remaining fully compatible with the PDF runtime established in Phase P.4.
|
||||
@@ -4,7 +4,7 @@ import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/servic
|
||||
import {
|
||||
getDocumentTemplate,
|
||||
softDeleteDocumentTemplate,
|
||||
updateDocumentTemplate
|
||||
updateDocumentTemplate,
|
||||
} from '@/features/foundation/document-template/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
@@ -20,14 +20,14 @@ const documentTemplateSchema = z.object({
|
||||
templateName: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
isDefault: z.boolean().optional(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const template = await getDocumentTemplate(id, organization.id);
|
||||
|
||||
@@ -35,16 +35,21 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Document template loaded successfully',
|
||||
template
|
||||
template,
|
||||
});
|
||||
} 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 to load document template' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable load document template' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +57,16 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate,
|
||||
});
|
||||
const payload = documentTemplateSchema.parse(await request.json());
|
||||
const before = await getDocumentTemplate(id, organization.id);
|
||||
const updated = await updateDocumentTemplate(id, organization.id, session.user.id, payload);
|
||||
const updated = await updateDocumentTemplate(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
);
|
||||
|
||||
await auditUpdate({
|
||||
organizationId: organization.id,
|
||||
@@ -64,24 +74,30 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
entityType: 'crm_document_template',
|
||||
entityId: id,
|
||||
beforeData: before,
|
||||
afterData: updated
|
||||
afterData: updated,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template updated successfully'
|
||||
message: 'Document template updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to update document template' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable update document template' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +105,14 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateDelete
|
||||
permission: PERMISSIONS.crmDocumentTemplateDelete,
|
||||
});
|
||||
const before = await getDocumentTemplate(id, organization.id);
|
||||
const updated = await softDeleteDocumentTemplate(id, organization.id, session.user.id);
|
||||
const updated = await softDeleteDocumentTemplate(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
);
|
||||
|
||||
await auditDelete({
|
||||
organizationId: organization.id,
|
||||
@@ -100,20 +120,25 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
entityType: 'crm_document_template',
|
||||
entityId: id,
|
||||
beforeData: before,
|
||||
afterData: updated
|
||||
afterData: updated,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template deleted successfully'
|
||||
message: 'Document template deleted successfully',
|
||||
});
|
||||
} 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 to delete document template' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable delete document template' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
createDocumentTemplateVersion,
|
||||
listDocumentTemplateVersions
|
||||
listDocumentTemplateVersions,
|
||||
} from '@/features/foundation/document-template/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
@@ -17,14 +17,14 @@ const versionSchema = z.object({
|
||||
filePath: z.string().optional().nullable(),
|
||||
schemaJson: z.unknown(),
|
||||
previewImageUrl: z.string().optional().nullable(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const items = await listDocumentTemplateVersions(id, organization.id);
|
||||
|
||||
@@ -32,16 +32,21 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Document template versions loaded successfully',
|
||||
items
|
||||
items,
|
||||
});
|
||||
} 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 to load document template versions' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable load document template versions' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,33 +54,44 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate,
|
||||
});
|
||||
const payload = versionSchema.parse(await request.json());
|
||||
const created = await createDocumentTemplateVersion(id, organization.id, session.user.id, payload);
|
||||
const created = await createDocumentTemplateVersion(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
);
|
||||
|
||||
await auditCreate({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: created.id,
|
||||
afterData: created
|
||||
afterData: created,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version created successfully'
|
||||
message: 'Document template version created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to create document template version' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable create document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
createDocumentTemplate,
|
||||
listDocumentTemplates
|
||||
listDocumentTemplates,
|
||||
} from '@/features/foundation/document-template/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
@@ -15,68 +15,87 @@ const documentTemplateSchema = z.object({
|
||||
templateName: z.string().min(1),
|
||||
description: z.string().optional().nullable(),
|
||||
isDefault: z.boolean().optional(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const { searchParams } = request.nextUrl;
|
||||
const isActiveParam = searchParams.get('isActive');
|
||||
const items = await listDocumentTemplates(organization.id, {
|
||||
documentType: searchParams.get('documentType') ?? undefined,
|
||||
fileType: searchParams.get('fileType') ?? undefined,
|
||||
isActive: searchParams.get('isActive') ?? undefined
|
||||
isActive:
|
||||
isActiveParam === 'true' || isActiveParam === 'false'
|
||||
? isActiveParam
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Document templates loaded successfully',
|
||||
items
|
||||
items,
|
||||
});
|
||||
} 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 to load document templates' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable load document templates' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateCreate
|
||||
permission: PERMISSIONS.crmDocumentTemplateCreate,
|
||||
});
|
||||
const payload = documentTemplateSchema.parse(await request.json());
|
||||
const created = await createDocumentTemplate(organization.id, session.user.id, payload);
|
||||
const created = await createDocumentTemplate(
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
);
|
||||
|
||||
await auditCreate({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template',
|
||||
entityId: created.id,
|
||||
afterData: created
|
||||
afterData: created,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template created successfully'
|
||||
message: 'Document template created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to create document template' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable create document template' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { compareDocumentTemplateVersions } from '@/features/foundation/document-template/server/management-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
const compareSchema = z.object({
|
||||
leftVersionId: z.string().min(1),
|
||||
rightVersionId: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const payload = compareSchema.parse(await request.json());
|
||||
const comparison = await compareDocumentTemplateVersions({
|
||||
templateId: id,
|
||||
leftVersionId: payload.leftVersionId,
|
||||
rightVersionId: payload.rightVersionId,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template versions compared successfully',
|
||||
comparison,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable compare document template versions' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { activateDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplateActivate,
|
||||
});
|
||||
const version = await activateDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'activate',
|
||||
afterData: version,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version activated 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 activate document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { archiveDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplateArchive,
|
||||
});
|
||||
const version = await archiveDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'archive',
|
||||
afterData: version,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version archived 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 archive document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getManagedDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const version = await getManagedDocumentTemplateVersion(id, organization.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version management data loaded 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 load document template version management data' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { previewDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const preview = await previewDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template preview generated successfully',
|
||||
preview,
|
||||
});
|
||||
} 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 generate document template preview' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { publishDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplatePublish,
|
||||
});
|
||||
const version = await publishDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'publish',
|
||||
afterData: version,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version published 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 publish document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { rollbackDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplateRollback,
|
||||
});
|
||||
const version = await rollbackDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'rollback',
|
||||
afterData: version,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version rolled back 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 rollback document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { auditUpdate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
setDocumentTemplateVersionActive,
|
||||
updateDocumentTemplateVersion
|
||||
updateDocumentTemplateVersion,
|
||||
} from '@/features/foundation/document-template/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
@@ -17,19 +17,27 @@ const versionSchema = z.object({
|
||||
filePath: z.string().optional().nullable(),
|
||||
schemaJson: z.unknown().optional(),
|
||||
previewImageUrl: z.string().optional().nullable(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
runtimeVersion: z.string().optional().nullable(),
|
||||
templateVariant: z.string().optional().nullable(),
|
||||
brand: z.string().optional().nullable(),
|
||||
cloneFromVersionId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate,
|
||||
});
|
||||
const payload = versionSchema.parse(await request.json());
|
||||
|
||||
if (Object.keys(payload).length === 1 && typeof payload.isActive === 'boolean') {
|
||||
const result = await setDocumentTemplateVersionActive(id, organization.id, payload.isActive);
|
||||
const result = await setDocumentTemplateVersionActive(
|
||||
id,
|
||||
organization.id,
|
||||
payload.isActive,
|
||||
);
|
||||
|
||||
await auditUpdate({
|
||||
organizationId: organization.id,
|
||||
@@ -37,12 +45,12 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
beforeData: result.before,
|
||||
afterData: result.after
|
||||
afterData: result.after,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version status updated successfully'
|
||||
message: 'Document template version status updated successfully',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,29 +61,29 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
afterData: updated
|
||||
afterData: updated,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version updated successfully'
|
||||
message: 'Document template version updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable to update document template version' },
|
||||
{ status: 500 }
|
||||
{ message: 'Unable update document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { validateAndPersistDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplateUpdate,
|
||||
});
|
||||
const validation = await validateAndPersistDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'validate',
|
||||
afterData: validation,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version validated successfully',
|
||||
validation,
|
||||
});
|
||||
} 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 validate document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { TemplateJsonSheet } from './template-json-sheet';
|
||||
import { TemplateVersionManagementPanel } from './template-version-management-panel';
|
||||
import {
|
||||
createDocumentTemplateMappingMutation,
|
||||
createDocumentTemplateMutation,
|
||||
@@ -527,7 +528,10 @@ function MappingDialog({
|
||||
function TemplateDetailCard({ templateId }: { templateId: string }) {
|
||||
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
|
||||
const template = data.template;
|
||||
const [versionDialogOpen, setVersionDialogOpen] = React.useState(false);
|
||||
const [versionDialogState, setVersionDialogState] = React.useState<{
|
||||
open: boolean;
|
||||
version?: DocumentTemplateVersionDetail;
|
||||
}>({ open: false });
|
||||
const [mappingDialogState, setMappingDialogState] = React.useState<{
|
||||
open: boolean;
|
||||
version?: DocumentTemplateVersionDetail;
|
||||
@@ -569,7 +573,7 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<Button onClick={() => setVersionDialogOpen(true)}>
|
||||
<Button onClick={() => setVersionDialogState({ open: true, version: undefined })}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Add Version
|
||||
</Button>
|
||||
@@ -617,7 +621,15 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
|
||||
filePath={version.filePath}
|
||||
schemaJson={version.schemaJson}
|
||||
/>
|
||||
<Button variant='outline' onClick={() => setVersionDialogOpen(true)}>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setVersionDialogState({
|
||||
open: true,
|
||||
version,
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit Version
|
||||
</Button>
|
||||
<Button
|
||||
@@ -634,6 +646,11 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TemplateVersionManagementPanel
|
||||
templateId={template.id}
|
||||
version={version}
|
||||
versions={template.versions}
|
||||
/>
|
||||
<div className='space-y-3'>
|
||||
{version.mappings.map((mapping) => (
|
||||
<div key={mapping.id} className='rounded-lg border p-4'>
|
||||
@@ -699,10 +716,12 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
|
||||
</Tabs>
|
||||
|
||||
<VersionDialog
|
||||
open={versionDialogOpen}
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
open={versionDialogState.open}
|
||||
onOpenChange={(open) =>
|
||||
setVersionDialogState((current) => ({ ...current, open }))
|
||||
}
|
||||
templateId={template.id}
|
||||
version={undefined}
|
||||
version={versionDialogState.version}
|
||||
/>
|
||||
{mappingDialogState.open && mappingDialogState.version ? (
|
||||
<MappingDialog
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
activateDocumentTemplateVersionMutation,
|
||||
archiveDocumentTemplateVersionMutation,
|
||||
compareDocumentTemplateVersionsMutation,
|
||||
previewDocumentTemplateVersionMutation,
|
||||
publishDocumentTemplateVersionMutation,
|
||||
rollbackDocumentTemplateVersionMutation,
|
||||
validateDocumentTemplateVersionMutation,
|
||||
} from '../mutations';
|
||||
import { documentTemplateVersionManagementQueryOptions } from '../queries';
|
||||
import type {
|
||||
DocumentTemplateVersionAuditSummary,
|
||||
DocumentTemplateVersionDetail,
|
||||
DocumentTemplateVersionValidationSummary,
|
||||
} from '../types';
|
||||
|
||||
function getStatusBadgeVariant(status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN') {
|
||||
if (status === 'PASS') {
|
||||
return 'secondary' as const;
|
||||
}
|
||||
|
||||
if (status === 'FAIL') {
|
||||
return 'destructive' as const;
|
||||
}
|
||||
|
||||
return 'outline' as const;
|
||||
}
|
||||
|
||||
function getLifecycleBadgeVariant(
|
||||
status: DocumentTemplateVersionDetail['lifecycleStatus'],
|
||||
) {
|
||||
if (status === 'active') {
|
||||
return 'secondary' as const;
|
||||
}
|
||||
|
||||
if (status === 'archived') {
|
||||
return 'outline' as const;
|
||||
}
|
||||
|
||||
return 'default' as const;
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className='rounded-lg bg-muted/40 p-3'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='mt-1 text-sm font-medium'>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueList({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
}) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2 rounded-lg border p-3'>
|
||||
<div className='text-sm font-medium'>{title}</div>
|
||||
<div className='space-y-1 text-sm'>
|
||||
{items.map((item) => (
|
||||
<div key={item} className='text-muted-foreground break-all'>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeIssueList({
|
||||
validation,
|
||||
}: {
|
||||
validation: DocumentTemplateVersionValidationSummary | null | undefined;
|
||||
}) {
|
||||
if (!validation?.runtimeIssues.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2 rounded-lg border p-3'>
|
||||
<div className='text-sm font-medium'>Runtime Issues</div>
|
||||
<div className='space-y-2'>
|
||||
{validation.runtimeIssues.map((issue) => (
|
||||
<div key={`${issue.code}-${issue.message}`} className='rounded-md bg-muted/40 p-2 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant={issue.severity === 'error' ? 'destructive' : 'outline'}>
|
||||
{issue.severity}
|
||||
</Badge>
|
||||
<span className='font-medium'>{issue.code}</span>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1'>{issue.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
templateId,
|
||||
currentVersion,
|
||||
versions,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templateId: string;
|
||||
currentVersion: DocumentTemplateVersionDetail;
|
||||
versions: DocumentTemplateVersionDetail[];
|
||||
}) {
|
||||
const [rightVersionId, setRightVersionId] = React.useState(
|
||||
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
|
||||
);
|
||||
const compareMutation = useMutation({
|
||||
...compareDocumentTemplateVersionsMutation,
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Compare failed'),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setRightVersionId(
|
||||
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
|
||||
);
|
||||
}
|
||||
}, [currentVersion.id, open, versions]);
|
||||
|
||||
async function handleCompare() {
|
||||
const result = await compareMutation.mutateAsync({
|
||||
templateId,
|
||||
leftVersionId: currentVersion.id,
|
||||
rightVersionId,
|
||||
});
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Compare Versions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Compare current version against another version in the same template family.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='grid gap-4'>
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>Base Version</div>
|
||||
<div className='rounded-lg border px-3 py-2 text-sm'>{currentVersion.version}</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>Compare With</div>
|
||||
<select
|
||||
className='flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm'
|
||||
value={rightVersionId}
|
||||
onChange={(event) => setRightVersionId(event.target.value)}
|
||||
>
|
||||
{versions
|
||||
.filter((item) => item.id !== currentVersion.id)
|
||||
.map((version) => (
|
||||
<option key={version.id} value={version.id}>
|
||||
{version.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCompare}
|
||||
isLoading={compareMutation.isPending}
|
||||
disabled={!rightVersionId || rightVersionId === currentVersion.id}
|
||||
>
|
||||
Compare
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplateVersionManagementPanel({
|
||||
templateId,
|
||||
version,
|
||||
versions,
|
||||
}: {
|
||||
templateId: string;
|
||||
version: DocumentTemplateVersionDetail;
|
||||
versions: DocumentTemplateVersionDetail[];
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(
|
||||
documentTemplateVersionManagementQueryOptions(version.id),
|
||||
);
|
||||
const managedVersion = data.version;
|
||||
const [compareOpen, setCompareOpen] = React.useState(false);
|
||||
|
||||
const validateMutation = useMutation({
|
||||
...validateDocumentTemplateVersionMutation,
|
||||
onSuccess: (result) => toast.success(`Validation: ${result.validation.status}`),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Validate failed'),
|
||||
});
|
||||
const publishMutation = useMutation({
|
||||
...publishDocumentTemplateVersionMutation,
|
||||
onSuccess: () => toast.success('Version published'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Publish failed'),
|
||||
});
|
||||
const activateMutation = useMutation({
|
||||
...activateDocumentTemplateVersionMutation,
|
||||
onSuccess: () => toast.success('Version activated'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Activate failed'),
|
||||
});
|
||||
const rollbackMutation = useMutation({
|
||||
...rollbackDocumentTemplateVersionMutation,
|
||||
onSuccess: () => toast.success('Rollback completed'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Rollback failed'),
|
||||
});
|
||||
const archiveMutation = useMutation({
|
||||
...archiveDocumentTemplateVersionMutation,
|
||||
onSuccess: () => toast.success('Version archived'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Archive failed'),
|
||||
});
|
||||
const previewMutation = useMutation({
|
||||
...previewDocumentTemplateVersionMutation,
|
||||
onSuccess: (result) => {
|
||||
const url = `data:${result.preview.contentType};base64,${result.preview.base64}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
toast.success(`Preview ready (${result.preview.pageCount} pages)`);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Preview failed'),
|
||||
});
|
||||
|
||||
const validation = managedVersion.validationSummary ?? null;
|
||||
const audit = managedVersion.auditSummary ?? 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 (
|
||||
<>
|
||||
<div className='space-y-4 rounded-lg border p-4'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='space-y-2'>
|
||||
<div className='font-medium'>Version Lifecycle Management</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge variant={getLifecycleBadgeVariant(managedVersion.lifecycleStatus)}>
|
||||
{managedVersion.lifecycleStatus ?? 'draft'}
|
||||
</Badge>
|
||||
<Badge variant={managedVersion.isActive ? 'secondary' : 'outline'}>
|
||||
{managedVersion.isActive ? 'Active Runtime' : 'Inactive Runtime'}
|
||||
</Badge>
|
||||
{validation ? (
|
||||
<Badge variant={getStatusBadgeVariant(validation.status)}>
|
||||
Validation {validation.status}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => previewMutation.mutate(version.id)}
|
||||
isLoading={previewMutation.isPending}
|
||||
>
|
||||
<Icons.post className='mr-2 h-4 w-4' />
|
||||
Preview
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => setCompareOpen(true)}>
|
||||
<Icons.share className='mr-2 h-4 w-4' />
|
||||
Compare
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => validateMutation.mutate(version.id)}
|
||||
isLoading={validateMutation.isPending}
|
||||
>
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => publishMutation.mutate({ templateId, versionId: version.id })}
|
||||
isLoading={publishMutation.isPending}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => activateMutation.mutate({ templateId, versionId: version.id })}
|
||||
isLoading={activateMutation.isPending}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => rollbackMutation.mutate({ templateId, versionId: version.id })}
|
||||
isLoading={rollbackMutation.isPending}
|
||||
>
|
||||
Rollback
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => archiveMutation.mutate({ templateId, versionId: version.id })}
|
||||
isLoading={archiveMutation.isPending}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<SummaryCard
|
||||
label='Runtime Version'
|
||||
value={managedVersion.runtimeVersion ?? 'Not set'}
|
||||
/>
|
||||
<SummaryCard
|
||||
label='Template Variant'
|
||||
value={managedVersion.templateVariant ?? 'Not set'}
|
||||
/>
|
||||
<SummaryCard label='Brand' value={managedVersion.brand ?? 'Not set'} />
|
||||
<SummaryCard
|
||||
label='Published At'
|
||||
value={managedVersion.publishedAt ?? 'Not published'}
|
||||
/>
|
||||
<SummaryCard
|
||||
label='Activated At'
|
||||
value={managedVersion.activatedAt ?? 'Not activated'}
|
||||
/>
|
||||
<SummaryCard
|
||||
label='Archived At'
|
||||
value={managedVersion.archivedAt ?? 'Not archived'}
|
||||
/>
|
||||
<SummaryCard
|
||||
label='Previous Version'
|
||||
value={managedVersion.previousVersionId ?? 'None'}
|
||||
/>
|
||||
<SummaryCard
|
||||
label='Next Version'
|
||||
value={managedVersion.nextVersionId ?? 'None'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='font-medium'>Validation Summary</div>
|
||||
{validation ? (
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<SummaryCard
|
||||
label='Validation Status'
|
||||
value={<Badge variant={getStatusBadgeVariant(validation.status)}>{validation.status}</Badge>}
|
||||
/>
|
||||
<SummaryCard
|
||||
label='Validated At'
|
||||
value={validation.validatedAt ?? 'Not validated'}
|
||||
/>
|
||||
<SummaryCard
|
||||
label='Required Missing'
|
||||
value={validation.requiredFieldsMissing.length}
|
||||
/>
|
||||
<SummaryCard
|
||||
label='Unmapped Placeholders'
|
||||
value={validation.unmappedPlaceholders.length}
|
||||
/>
|
||||
</div>
|
||||
<IssueList
|
||||
title='Required Fields Missing'
|
||||
items={validation.requiredFieldsMissing}
|
||||
/>
|
||||
<IssueList title='Duplicate Fields' items={validation.duplicateFields} />
|
||||
<IssueList
|
||||
title='Unmapped Placeholders'
|
||||
items={validation.unmappedPlaceholders}
|
||||
/>
|
||||
<RuntimeIssueList validation={validation} />
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||
Validation has not been run for this version yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='font-medium'>Audit Summary</div>
|
||||
{renderAuditSummary(audit)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CompareDialog
|
||||
open={compareOpen}
|
||||
onOpenChange={setCompareOpen}
|
||||
templateId={templateId}
|
||||
currentVersion={version}
|
||||
versions={versions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
activateDocumentTemplateVersion,
|
||||
archiveDocumentTemplateVersion,
|
||||
compareDocumentTemplateVersions,
|
||||
createDocumentTemplate,
|
||||
createDocumentTemplateMapping,
|
||||
createDocumentTemplateVersion,
|
||||
deleteDocumentTemplate,
|
||||
deleteDocumentTemplateMapping,
|
||||
previewDocumentTemplateVersion,
|
||||
publishDocumentTemplateVersion,
|
||||
rollbackDocumentTemplateVersion,
|
||||
setDocumentTemplateVersionActive,
|
||||
updateDocumentTemplate,
|
||||
updateDocumentTemplateMapping,
|
||||
updateDocumentTemplateVersion
|
||||
updateDocumentTemplateVersion,
|
||||
validateDocumentTemplateVersion,
|
||||
} from './service';
|
||||
import { documentTemplateKeys } from './queries';
|
||||
import type {
|
||||
DocumentTemplateMappingMutationPayload,
|
||||
DocumentTemplateMutationPayload,
|
||||
DocumentTemplateVersionMutationPayload
|
||||
DocumentTemplateVersionMutationPayload,
|
||||
} from './types';
|
||||
|
||||
async function invalidateTemplateLists() {
|
||||
@@ -25,10 +32,16 @@ async function invalidateTemplateLists() {
|
||||
async function invalidateTemplateDetail(id: string) {
|
||||
await Promise.all([
|
||||
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(id) }),
|
||||
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) })
|
||||
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function invalidateVersionManagement(versionId: string) {
|
||||
await getQueryClient().invalidateQueries({
|
||||
queryKey: documentTemplateKeys.management(versionId),
|
||||
});
|
||||
}
|
||||
|
||||
export const createDocumentTemplateMutation = mutationOptions({
|
||||
mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data),
|
||||
onSettled: async (_data, error) => {
|
||||
@@ -120,8 +133,7 @@ export const createDocumentTemplateMappingMutation = mutationOptions({
|
||||
versionId: string;
|
||||
values: DocumentTemplateMappingMutationPayload;
|
||||
}) => {
|
||||
void templateId;
|
||||
return createDocumentTemplateMapping(versionId, values);
|
||||
return createDocumentTemplateMapping(templateId, versionId, values);
|
||||
},
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
@@ -159,5 +171,101 @@ export const deleteDocumentTemplateMappingMutation = mutationOptions({
|
||||
if (!error) {
|
||||
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const validateDocumentTemplateVersionMutation = mutationOptions({
|
||||
mutationFn: (versionId: string) => validateDocumentTemplateVersion(versionId),
|
||||
});
|
||||
|
||||
export const publishDocumentTemplateVersionMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
versionId,
|
||||
}: {
|
||||
templateId: string;
|
||||
versionId: string;
|
||||
}) => publishDocumentTemplateVersion(versionId),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateTemplateLists(),
|
||||
invalidateTemplateDetail(variables.templateId),
|
||||
invalidateVersionManagement(variables.versionId),
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const activateDocumentTemplateVersionMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
versionId,
|
||||
}: {
|
||||
templateId: string;
|
||||
versionId: string;
|
||||
}) => activateDocumentTemplateVersion(versionId),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateTemplateLists(),
|
||||
invalidateTemplateDetail(variables.templateId),
|
||||
invalidateVersionManagement(variables.versionId),
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const rollbackDocumentTemplateVersionMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
versionId,
|
||||
}: {
|
||||
templateId: string;
|
||||
versionId: string;
|
||||
}) => rollbackDocumentTemplateVersion(versionId),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateTemplateLists(),
|
||||
invalidateTemplateDetail(variables.templateId),
|
||||
invalidateVersionManagement(variables.versionId),
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveDocumentTemplateVersionMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
versionId,
|
||||
}: {
|
||||
templateId: string;
|
||||
versionId: string;
|
||||
}) => archiveDocumentTemplateVersion(versionId),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateTemplateLists(),
|
||||
invalidateTemplateDetail(variables.templateId),
|
||||
invalidateVersionManagement(variables.versionId),
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const previewDocumentTemplateVersionMutation = mutationOptions({
|
||||
mutationFn: (versionId: string) => previewDocumentTemplateVersion(versionId),
|
||||
});
|
||||
|
||||
export const compareDocumentTemplateVersionsMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
leftVersionId,
|
||||
rightVersionId,
|
||||
}: {
|
||||
templateId: string;
|
||||
leftVersionId: string;
|
||||
rightVersionId: string;
|
||||
}) => compareDocumentTemplateVersions(templateId, leftVersionId, rightVersionId),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getDocumentTemplateById, getDocumentTemplates, getDocumentTemplateVersions } from './service';
|
||||
import {
|
||||
getDocumentTemplateById,
|
||||
getDocumentTemplates,
|
||||
getDocumentTemplateVersionManagement,
|
||||
getDocumentTemplateVersions,
|
||||
} from './service';
|
||||
import type { DocumentTemplateFilters } from './types';
|
||||
|
||||
export const documentTemplateKeys = {
|
||||
@@ -10,7 +15,10 @@ export const documentTemplateKeys = {
|
||||
details: () => [...documentTemplateKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...documentTemplateKeys.details(), id] as const,
|
||||
versionsRoot: () => [...documentTemplateKeys.all, 'versions'] as const,
|
||||
versions: (id: string) => [...documentTemplateKeys.versionsRoot(), id] as const
|
||||
versions: (id: string) => [...documentTemplateKeys.versionsRoot(), id] as const,
|
||||
managementRoot: () => [...documentTemplateKeys.all, 'management'] as const,
|
||||
management: (versionId: string) =>
|
||||
[...documentTemplateKeys.managementRoot(), versionId] as const,
|
||||
};
|
||||
|
||||
export const documentTemplatesQueryOptions = (filters: DocumentTemplateFilters = {}) =>
|
||||
@@ -28,5 +36,11 @@ export const documentTemplateByIdOptions = (id: string) =>
|
||||
export const documentTemplateVersionsQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: documentTemplateKeys.versions(id),
|
||||
queryFn: () => getDocumentTemplateVersions(id)
|
||||
queryFn: () => getDocumentTemplateVersions(id),
|
||||
});
|
||||
|
||||
export const documentTemplateVersionManagementQueryOptions = (versionId: string) =>
|
||||
queryOptions({
|
||||
queryKey: documentTemplateKeys.management(versionId),
|
||||
queryFn: () => getDocumentTemplateVersionManagement(versionId),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { and, asc, eq, isNull, lt } from 'drizzle-orm';
|
||||
import { PDFDocument } from '@pdfme/pdf-lib';
|
||||
import {
|
||||
crmDocumentTemplateVersions,
|
||||
crmDocumentTemplates,
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { generatePdfFromTemplate } from '@/features/foundation/pdf-generator/server/service';
|
||||
import { buildQuotationPdfRuntime } from '@/features/crm/quotations/document/server/quotation-pdf-runtime';
|
||||
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 {
|
||||
PDFME_STATIC_LABEL_FIELD_KEYS,
|
||||
buildAuditPayload,
|
||||
createSqlClient,
|
||||
extractTemplateFields,
|
||||
getEffectiveMappedKeys,
|
||||
normalizeTemplateInputAliases,
|
||||
summarizeIssues,
|
||||
} from '../../../../../scripts/pdf-audit-utils';
|
||||
|
||||
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[] = [
|
||||
'customer_name',
|
||||
'quotation_code_data',
|
||||
'quotation_date_data',
|
||||
'app1',
|
||||
'app2',
|
||||
'app3',
|
||||
] 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 {
|
||||
if (row.isActive) {
|
||||
return 'active';
|
||||
}
|
||||
return getManagementMetadata(row.schemaJson).lifecycleStatus ?? 'draft';
|
||||
}
|
||||
|
||||
async function getVersionRow(versionId: string, organizationId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(crmDocumentTemplateVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(crmDocumentTemplateVersions.id, versionId),
|
||||
eq(crmDocumentTemplateVersions.organizationId, organizationId),
|
||||
isNull(crmDocumentTemplateVersions.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
throw new AuthError('Document template version not found', 404);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async function listSiblingVersionRows(templateId: string, organizationId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(crmDocumentTemplateVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(crmDocumentTemplateVersions.templateId, templateId),
|
||||
eq(crmDocumentTemplateVersions.organizationId, organizationId),
|
||||
isNull(crmDocumentTemplateVersions.deletedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
|
||||
}
|
||||
|
||||
function decorateVersionDetail(args: {
|
||||
version: DocumentTemplateVersionDetail;
|
||||
siblings: DocumentTemplateVersionDetail[];
|
||||
auditSummary: DocumentTemplateVersionAuditSummary | null;
|
||||
validationSummary?: DocumentTemplateVersionValidationSummary | null;
|
||||
}): DocumentTemplateVersionDetail {
|
||||
const metadata = getManagementMetadata(args.version.schemaJson);
|
||||
const siblingIds = args.siblings.map((item) => item.id);
|
||||
const currentIndex = siblingIds.indexOf(args.version.id);
|
||||
|
||||
return {
|
||||
...args.version,
|
||||
lifecycleStatus: args.version.isActive
|
||||
? 'active'
|
||||
: metadata.lifecycleStatus ?? args.version.lifecycleStatus ?? 'draft',
|
||||
runtimeVersion:
|
||||
metadata.runtimeVersion ??
|
||||
args.version.runtimeVersion ??
|
||||
CURRENT_TEMPLATE_RUNTIME_VERSION,
|
||||
templateVariant:
|
||||
metadata.templateVariant ?? args.version.templateVariant ?? null,
|
||||
brand: metadata.brand ?? args.version.brand ?? null,
|
||||
publishedAt: metadata.publishedAt ?? args.version.publishedAt ?? null,
|
||||
publishedBy: metadata.publishedBy ?? args.version.publishedBy ?? null,
|
||||
activatedAt: metadata.activatedAt ?? args.version.activatedAt ?? null,
|
||||
activatedBy: metadata.activatedBy ?? args.version.activatedBy ?? null,
|
||||
archivedAt: metadata.archivedAt ?? args.version.archivedAt ?? null,
|
||||
archivedBy: metadata.archivedBy ?? args.version.archivedBy ?? null,
|
||||
previousVersionId:
|
||||
metadata.previousVersionId ??
|
||||
args.version.previousVersionId ??
|
||||
(currentIndex > 0 ? siblingIds[currentIndex - 1] : null),
|
||||
nextVersionId:
|
||||
metadata.nextVersionId ??
|
||||
args.version.nextVersionId ??
|
||||
(currentIndex >= 0 && currentIndex < siblingIds.length - 1
|
||||
? siblingIds[currentIndex + 1]
|
||||
: null),
|
||||
auditSummary: args.auditSummary,
|
||||
validationSummary: args.validationSummary ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAuditSummary(
|
||||
version: DocumentTemplateVersionDetail,
|
||||
): Promise<DocumentTemplateVersionAuditSummary | null> {
|
||||
const auditReportPath = path.resolve(
|
||||
process.cwd(),
|
||||
'docs/implementation/pdf-audit-report.json',
|
||||
);
|
||||
const visualReportPath = path.resolve(
|
||||
process.cwd(),
|
||||
'artifacts/pdf-visual/visual-regression-report.json',
|
||||
);
|
||||
|
||||
const auditSummary: DocumentTemplateVersionAuditSummary = {
|
||||
status: 'WARNING',
|
||||
auditedAt: null,
|
||||
runtimeVersion: version.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
|
||||
visualRegressionStatus: 'NOT_RUN',
|
||||
visualRegressionAt: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const auditReport = JSON.parse(await readFile(auditReportPath, 'utf8')) as {
|
||||
generatedAt?: string;
|
||||
overallStatus?: 'PASS' | 'WARNING' | 'FAIL';
|
||||
};
|
||||
auditSummary.auditedAt = auditReport.generatedAt ?? null;
|
||||
auditSummary.status = auditReport.overallStatus ?? 'WARNING';
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const visualReport = JSON.parse(await readFile(visualReportPath, 'utf8')) as {
|
||||
generatedAt?: string;
|
||||
overallStatus?: 'PASS' | 'WARNING' | 'FAIL';
|
||||
};
|
||||
auditSummary.visualRegressionAt = visualReport.generatedAt ?? null;
|
||||
auditSummary.visualRegressionStatus =
|
||||
version.templateVariant === 'product-v1'
|
||||
? visualReport.overallStatus ?? 'WARNING'
|
||||
: 'NOT_RUN';
|
||||
} catch {}
|
||||
|
||||
return auditSummary;
|
||||
}
|
||||
|
||||
async function buildFixtureDocumentData(): Promise<QuotationDocumentData> {
|
||||
const sql = createSqlClient();
|
||||
try {
|
||||
const payload = await buildAuditPayload(sql);
|
||||
return payload.documentData as unknown as QuotationDocumentData;
|
||||
} finally {
|
||||
await sql.end({ timeout: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
async function getTemplateContext(versionId: string, organizationId: string): Promise<{
|
||||
template: DocumentTemplateDetail;
|
||||
version: DocumentTemplateVersionDetail;
|
||||
siblings: DocumentTemplateVersionDetail[];
|
||||
}> {
|
||||
const row = await getVersionRow(versionId, organizationId);
|
||||
const template = await getDocumentTemplate(row.templateId, organizationId);
|
||||
const siblings = template.versions;
|
||||
const version = siblings.find((item) => item.id === versionId);
|
||||
|
||||
if (!version) {
|
||||
throw new AuthError('Document template version not found', 404);
|
||||
}
|
||||
|
||||
return {
|
||||
template,
|
||||
version,
|
||||
siblings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateDocumentTemplateVersion(
|
||||
versionId: string,
|
||||
organizationId: string,
|
||||
): Promise<DocumentTemplateVersionValidationSummary> {
|
||||
const { template, version, siblings } = await getTemplateContext(
|
||||
versionId,
|
||||
organizationId,
|
||||
);
|
||||
const fields = extractTemplateFields(version.schemaJson);
|
||||
const mappedKeys = getEffectiveMappedKeys(fields, version.mappings);
|
||||
const schemaFieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
|
||||
const hasProductSection = fields.some(
|
||||
(field) => field.name === '__section_role__product_items',
|
||||
);
|
||||
const requiredFieldsMissing = REQUIRED_TEMPLATE_PLACEHOLDERS.filter(
|
||||
(field) => !schemaFieldNames.has(field),
|
||||
);
|
||||
|
||||
if (hasProductSection && !schemaFieldNames.has('items_table')) {
|
||||
requiredFieldsMissing.push('items_table');
|
||||
}
|
||||
|
||||
const duplicateFieldCounts = new Map<string, number>();
|
||||
for (const field of fields) {
|
||||
if (!field.name) {
|
||||
continue;
|
||||
}
|
||||
duplicateFieldCounts.set(
|
||||
field.name,
|
||||
(duplicateFieldCounts.get(field.name) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
const duplicateFields = [...duplicateFieldCounts.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([name]) => name)
|
||||
.sort();
|
||||
|
||||
const unmappedPlaceholders = [...schemaFieldNames]
|
||||
.filter(
|
||||
(fieldName) =>
|
||||
!fieldName.startsWith('__section_role__') &&
|
||||
!PDFME_STATIC_LABEL_FIELD_KEYS.includes(
|
||||
fieldName as (typeof PDFME_STATIC_LABEL_FIELD_KEYS)[number],
|
||||
) &&
|
||||
!mappedKeys.has(fieldName) &&
|
||||
!fieldName.startsWith('topic') &&
|
||||
!fieldName.startsWith('data_topic') &&
|
||||
!fieldName.startsWith('item_topic'),
|
||||
)
|
||||
.sort();
|
||||
|
||||
const documentData = await buildFixtureDocumentData();
|
||||
const templateInput = mapDocumentDataToTemplateInput(
|
||||
documentData as unknown as Record<string, unknown>,
|
||||
version.mappings,
|
||||
);
|
||||
const normalizedTemplateInput = normalizeTemplateInputAliases(fields, templateInput);
|
||||
const runtime = await buildQuotationPdfRuntime({
|
||||
documentData,
|
||||
template: {
|
||||
template,
|
||||
version,
|
||||
},
|
||||
mappings: version.mappings,
|
||||
templateInput: normalizedTemplateInput,
|
||||
});
|
||||
|
||||
const statuses: Array<'PASS' | 'WARNING' | 'FAIL'> = [];
|
||||
if (requiredFieldsMissing.length) {
|
||||
statuses.push('FAIL');
|
||||
}
|
||||
if (duplicateFields.length) {
|
||||
statuses.push('FAIL');
|
||||
}
|
||||
if (unmappedPlaceholders.length) {
|
||||
statuses.push('WARNING');
|
||||
}
|
||||
statuses.push(
|
||||
...runtime.issues.map((issue) => (issue.severity === 'error' ? 'FAIL' : 'WARNING')),
|
||||
);
|
||||
|
||||
return {
|
||||
status: summarizeIssues(statuses.length ? statuses : ['PASS']),
|
||||
validatedAt: new Date().toISOString(),
|
||||
requiredFieldsMissing,
|
||||
duplicateFields,
|
||||
unmappedPlaceholders,
|
||||
runtimeIssues: runtime.issues.map((issue) => ({
|
||||
code: issue.code,
|
||||
severity: issue.severity,
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function persistVersionManagementMetadata(args: {
|
||||
versionId: string;
|
||||
organizationId: string;
|
||||
schemaJson: unknown;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const [updated] = await db
|
||||
.update(crmDocumentTemplateVersions)
|
||||
.set({
|
||||
schemaJson: args.schemaJson,
|
||||
...(args.isActive === undefined ? {} : { isActive: args.isActive }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(crmDocumentTemplateVersions.id, args.versionId),
|
||||
eq(crmDocumentTemplateVersions.organizationId, args.organizationId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function getManagedDocumentTemplateVersion(
|
||||
versionId: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const { version, siblings } = await getTemplateContext(versionId, organizationId);
|
||||
const auditSummary = await loadAuditSummary(version);
|
||||
return decorateVersionDetail({
|
||||
version,
|
||||
siblings,
|
||||
auditSummary,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateAndPersistDocumentTemplateVersion(args: {
|
||||
versionId: string;
|
||||
organizationId: string;
|
||||
}) {
|
||||
const validation = await validateDocumentTemplateVersion(
|
||||
args.versionId,
|
||||
args.organizationId,
|
||||
);
|
||||
const row = await getVersionRow(args.versionId, args.organizationId);
|
||||
const metadata = getManagementMetadata(row.schemaJson);
|
||||
|
||||
if (validation.status !== 'FAIL') {
|
||||
await persistVersionManagementMetadata({
|
||||
versionId: args.versionId,
|
||||
organizationId: args.organizationId,
|
||||
schemaJson: withManagementMetadata(row.schemaJson, {
|
||||
...metadata,
|
||||
lifecycleStatus:
|
||||
inferLifecycleStatus(row) === 'draft' ? 'validated' : inferLifecycleStatus(row),
|
||||
validatedAt: validation.validatedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return validation;
|
||||
}
|
||||
|
||||
export async function publishDocumentTemplateVersion(args: {
|
||||
versionId: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const validation = await validateAndPersistDocumentTemplateVersion(args);
|
||||
if (validation.status === 'FAIL') {
|
||||
throw new AuthError('Template version validation failed. Publish rejected.', 409);
|
||||
}
|
||||
|
||||
const row = await getVersionRow(args.versionId, args.organizationId);
|
||||
const metadata = getManagementMetadata(row.schemaJson);
|
||||
await persistVersionManagementMetadata({
|
||||
versionId: args.versionId,
|
||||
organizationId: args.organizationId,
|
||||
isActive: false,
|
||||
schemaJson: withManagementMetadata(row.schemaJson, {
|
||||
...metadata,
|
||||
lifecycleStatus: 'published',
|
||||
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
|
||||
publishedAt: new Date().toISOString(),
|
||||
publishedBy: args.userId,
|
||||
validatedAt: validation.validatedAt,
|
||||
}),
|
||||
});
|
||||
|
||||
return getManagedDocumentTemplateVersion(args.versionId, args.organizationId);
|
||||
}
|
||||
|
||||
export async function activateDocumentTemplateVersion(args: {
|
||||
versionId: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const row = await getVersionRow(args.versionId, args.organizationId);
|
||||
const currentStatus = inferLifecycleStatus(row);
|
||||
if (!['validated', 'published', 'active'].includes(currentStatus)) {
|
||||
throw new AuthError('Only validated or published versions can be activated.', 409);
|
||||
}
|
||||
|
||||
const siblings = await listSiblingVersionRows(row.templateId, args.organizationId);
|
||||
const currentActive = siblings.find((item) => item.isActive && item.id !== row.id) ?? null;
|
||||
|
||||
if (currentActive) {
|
||||
const previousMetadata = getManagementMetadata(currentActive.schemaJson);
|
||||
await persistVersionManagementMetadata({
|
||||
versionId: currentActive.id,
|
||||
organizationId: args.organizationId,
|
||||
isActive: false,
|
||||
schemaJson: withManagementMetadata(currentActive.schemaJson, {
|
||||
...previousMetadata,
|
||||
lifecycleStatus: 'published',
|
||||
nextVersionId: row.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = getManagementMetadata(row.schemaJson);
|
||||
await persistVersionManagementMetadata({
|
||||
versionId: row.id,
|
||||
organizationId: args.organizationId,
|
||||
isActive: true,
|
||||
schemaJson: withManagementMetadata(row.schemaJson, {
|
||||
...metadata,
|
||||
lifecycleStatus: 'active',
|
||||
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
|
||||
publishedAt: metadata.publishedAt ?? new Date().toISOString(),
|
||||
publishedBy: metadata.publishedBy ?? args.userId,
|
||||
activatedAt: new Date().toISOString(),
|
||||
activatedBy: args.userId,
|
||||
previousVersionId: currentActive?.id ?? metadata.previousVersionId ?? null,
|
||||
nextVersionId: null,
|
||||
}),
|
||||
});
|
||||
|
||||
return getManagedDocumentTemplateVersion(row.id, args.organizationId);
|
||||
}
|
||||
|
||||
export async function rollbackDocumentTemplateVersion(args: {
|
||||
versionId: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const row = await getVersionRow(args.versionId, args.organizationId);
|
||||
if (!row.isActive) {
|
||||
throw new AuthError('Rollback is only allowed from the current active version.', 409);
|
||||
}
|
||||
|
||||
const metadata = getManagementMetadata(row.schemaJson);
|
||||
let targetVersionId = metadata.previousVersionId ?? null;
|
||||
|
||||
if (!targetVersionId) {
|
||||
const [fallback] = await db
|
||||
.select()
|
||||
.from(crmDocumentTemplateVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(crmDocumentTemplateVersions.templateId, row.templateId),
|
||||
eq(crmDocumentTemplateVersions.organizationId, args.organizationId),
|
||||
isNull(crmDocumentTemplateVersions.deletedAt),
|
||||
lt(crmDocumentTemplateVersions.createdAt, row.createdAt),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
|
||||
targetVersionId = fallback?.id ?? null;
|
||||
}
|
||||
|
||||
if (!targetVersionId) {
|
||||
throw new AuthError('No previous version available for rollback.', 409);
|
||||
}
|
||||
|
||||
return activateDocumentTemplateVersion({
|
||||
versionId: targetVersionId,
|
||||
organizationId: args.organizationId,
|
||||
userId: args.userId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveDocumentTemplateVersion(args: {
|
||||
versionId: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const row = await getVersionRow(args.versionId, args.organizationId);
|
||||
if (row.isActive) {
|
||||
throw new AuthError('Active versions must be rolled back before archive.', 409);
|
||||
}
|
||||
|
||||
const metadata = getManagementMetadata(row.schemaJson);
|
||||
await persistVersionManagementMetadata({
|
||||
versionId: row.id,
|
||||
organizationId: args.organizationId,
|
||||
isActive: false,
|
||||
schemaJson: withManagementMetadata(row.schemaJson, {
|
||||
...metadata,
|
||||
lifecycleStatus: 'archived',
|
||||
archivedAt: new Date().toISOString(),
|
||||
archivedBy: args.userId,
|
||||
}),
|
||||
});
|
||||
|
||||
return getManagedDocumentTemplateVersion(row.id, args.organizationId);
|
||||
}
|
||||
|
||||
export async function compareDocumentTemplateVersions(args: {
|
||||
templateId: string;
|
||||
leftVersionId: string;
|
||||
rightVersionId: string;
|
||||
organizationId: string;
|
||||
}): Promise<DocumentTemplateVersionCompareResponse['comparison']> {
|
||||
const template = await getDocumentTemplate(args.templateId, args.organizationId);
|
||||
const left = template.versions.find((item) => item.id === args.leftVersionId);
|
||||
const right = template.versions.find((item) => item.id === args.rightVersionId);
|
||||
|
||||
if (!left || !right) {
|
||||
throw new AuthError('Unable to compare template versions.', 404);
|
||||
}
|
||||
|
||||
const metadataFields: Array<keyof DocumentTemplateVersionDetail> = [
|
||||
'version',
|
||||
'filePath',
|
||||
'isActive',
|
||||
'lifecycleStatus',
|
||||
'runtimeVersion',
|
||||
'templateVariant',
|
||||
'brand',
|
||||
];
|
||||
|
||||
const metadataChanges = metadataFields
|
||||
.map((field) => ({
|
||||
field,
|
||||
left: (left[field] as string | number | boolean | null | undefined) ?? null,
|
||||
right: (right[field] as string | number | boolean | null | undefined) ?? null,
|
||||
}))
|
||||
.filter((item) => item.left !== item.right);
|
||||
|
||||
const leftPlaceholderSet = new Set(left.mappings.map((item) => item.placeholderKey));
|
||||
const rightPlaceholderSet = new Set(right.mappings.map((item) => item.placeholderKey));
|
||||
|
||||
const mappingDifferences = [
|
||||
...left.mappings.flatMap((mapping) => {
|
||||
const other = right.mappings.find(
|
||||
(candidate) => candidate.placeholderKey === mapping.placeholderKey,
|
||||
);
|
||||
if (!other) {
|
||||
return [];
|
||||
}
|
||||
const currentSignature = JSON.stringify({
|
||||
sourcePath: mapping.sourcePath,
|
||||
dataType: mapping.dataType,
|
||||
columns: mapping.columns.map((column) => ({
|
||||
columnName: column.columnName,
|
||||
sourceField: column.sourceField,
|
||||
formatMask: column.formatMask,
|
||||
})),
|
||||
});
|
||||
const otherSignature = JSON.stringify({
|
||||
sourcePath: other.sourcePath,
|
||||
dataType: other.dataType,
|
||||
columns: other.columns.map((column) => ({
|
||||
columnName: column.columnName,
|
||||
sourceField: column.sourceField,
|
||||
formatMask: column.formatMask,
|
||||
})),
|
||||
});
|
||||
return currentSignature === otherSignature
|
||||
? []
|
||||
: [{ placeholderKey: mapping.placeholderKey, difference: 'Mapping contract changed' }];
|
||||
}),
|
||||
];
|
||||
|
||||
const leftValidation = await validateDocumentTemplateVersion(left.id, args.organizationId);
|
||||
const rightValidation = await validateDocumentTemplateVersion(right.id, args.organizationId);
|
||||
|
||||
return {
|
||||
leftVersionId: left.id,
|
||||
rightVersionId: right.id,
|
||||
metadataChanges,
|
||||
placeholderOnlyLeft: [...leftPlaceholderSet].filter((item) => !rightPlaceholderSet.has(item)),
|
||||
placeholderOnlyRight: [...rightPlaceholderSet].filter((item) => !leftPlaceholderSet.has(item)),
|
||||
mappingDifferences,
|
||||
runtimeCompatibility: {
|
||||
leftStatus: leftValidation.status,
|
||||
rightStatus: rightValidation.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewDocumentTemplateVersion(args: {
|
||||
versionId: string;
|
||||
organizationId: string;
|
||||
}): Promise<DocumentTemplateVersionPreviewResponse['preview']> {
|
||||
const { template, version } = await getTemplateContext(args.versionId, args.organizationId);
|
||||
const documentData = await buildFixtureDocumentData();
|
||||
const fields = extractTemplateFields(version.schemaJson);
|
||||
const templateInput = normalizeTemplateInputAliases(
|
||||
fields,
|
||||
mapDocumentDataToTemplateInput(
|
||||
documentData as unknown as Record<string, unknown>,
|
||||
version.mappings,
|
||||
),
|
||||
);
|
||||
const runtime = await buildQuotationPdfRuntime({
|
||||
documentData,
|
||||
template: {
|
||||
template,
|
||||
version,
|
||||
},
|
||||
mappings: version.mappings,
|
||||
templateInput,
|
||||
});
|
||||
const buffer = await generatePdfFromTemplate({
|
||||
template: runtime.assembled.template,
|
||||
inputs: [runtime.assembled.templateInput],
|
||||
});
|
||||
const pdfDocument = await PDFDocument.load(buffer);
|
||||
|
||||
return {
|
||||
fileName: `${template.templateName}-${version.version}-preview.pdf`,
|
||||
contentType: 'application/pdf',
|
||||
base64: Buffer.from(buffer).toString('base64'),
|
||||
pageCount: pdfDocument.getPageCount(),
|
||||
source: 'audit-fixture',
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type {
|
||||
DocumentTemplateDetail,
|
||||
DocumentTemplateFilters,
|
||||
DocumentTemplateLifecycleStatus,
|
||||
DocumentTemplateListItem,
|
||||
DocumentTemplateMappingMutationPayload,
|
||||
DocumentTemplateMappingRecord,
|
||||
@@ -29,6 +30,62 @@ import type {
|
||||
ResolvedDocumentTemplate
|
||||
} 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: {
|
||||
isActive: boolean;
|
||||
metadata: DocumentTemplateManagementMetadata;
|
||||
}): DocumentTemplateLifecycleStatus {
|
||||
if (args.isActive) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
return args.metadata.lifecycleStatus ?? 'draft';
|
||||
}
|
||||
|
||||
function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): DocumentTemplateRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -51,6 +108,8 @@ function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): Docum
|
||||
function mapVersionRecord(
|
||||
row: typeof crmDocumentTemplateVersions.$inferSelect
|
||||
): DocumentTemplateVersionRecord {
|
||||
const metadata = getManagementMetadata(row.schemaJson);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
@@ -60,6 +119,21 @@ function mapVersionRecord(
|
||||
schemaJson: row.schemaJson,
|
||||
previewImageUrl: row.previewImageUrl,
|
||||
isActive: row.isActive,
|
||||
lifecycleStatus: inferLifecycleStatus({
|
||||
isActive: row.isActive,
|
||||
metadata
|
||||
}),
|
||||
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
|
||||
templateVariant: metadata.templateVariant ?? null,
|
||||
brand: metadata.brand ?? null,
|
||||
publishedAt: metadata.publishedAt ?? null,
|
||||
publishedBy: metadata.publishedBy ?? null,
|
||||
activatedAt: metadata.activatedAt ?? null,
|
||||
activatedBy: metadata.activatedBy ?? null,
|
||||
archivedAt: metadata.archivedAt ?? null,
|
||||
archivedBy: metadata.archivedBy ?? null,
|
||||
previousVersionId: metadata.previousVersionId ?? null,
|
||||
nextVersionId: metadata.nextVersionId ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||
|
||||
@@ -5,20 +5,22 @@ import type {
|
||||
DocumentTemplateListResponse,
|
||||
DocumentTemplateMappingMutationPayload,
|
||||
DocumentTemplateMutationPayload,
|
||||
DocumentTemplateVersionActionResponse,
|
||||
DocumentTemplateVersionCompareResponse,
|
||||
DocumentTemplateVersionMutationPayload,
|
||||
DocumentTemplateVersionPreviewResponse,
|
||||
DocumentTemplateVersionValidateResponse,
|
||||
DocumentTemplateVersionsResponse,
|
||||
MutationSuccessResponse
|
||||
MutationSuccessResponse,
|
||||
} from './types';
|
||||
|
||||
const BASE_PATH = '/crm/settings/document-templates';
|
||||
|
||||
export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.documentType) searchParams.set('documentType', filters.documentType);
|
||||
if (filters.fileType) searchParams.set('fileType', filters.fileType);
|
||||
if (filters.isActive) searchParams.set('isActive', filters.isActive);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return apiClient<DocumentTemplateListResponse>(`${BASE_PATH}${query ? `?${query}` : ''}`);
|
||||
}
|
||||
@@ -30,23 +32,23 @@ export async function getDocumentTemplateById(id: string) {
|
||||
export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(BASE_PATH, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDocumentTemplate(
|
||||
id: string,
|
||||
data: Partial<DocumentTemplateMutationPayload>
|
||||
data: Partial<DocumentTemplateMutationPayload>,
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDocumentTemplate(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,53 +58,104 @@ export async function getDocumentTemplateVersions(id: string) {
|
||||
|
||||
export async function createDocumentTemplateVersion(
|
||||
id: string,
|
||||
data: DocumentTemplateVersionMutationPayload
|
||||
data: DocumentTemplateVersionMutationPayload,
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}/versions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDocumentTemplateVersion(
|
||||
id: string,
|
||||
data: Partial<DocumentTemplateVersionMutationPayload>
|
||||
data: Partial<DocumentTemplateVersionMutationPayload>,
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setDocumentTemplateVersionActive(id: string, isActive: boolean) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive })
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDocumentTemplateMapping(
|
||||
templateId: string,
|
||||
versionId: string,
|
||||
data: DocumentTemplateMappingMutationPayload
|
||||
data: DocumentTemplateMappingMutationPayload,
|
||||
) {
|
||||
void templateId;
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${versionId}/mappings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDocumentTemplateMapping(
|
||||
id: string,
|
||||
data: Partial<DocumentTemplateMappingMutationPayload>
|
||||
mappingId: string,
|
||||
data: Partial<DocumentTemplateMappingMutationPayload>,
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${mappingId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDocumentTemplateMapping(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
|
||||
method: 'DELETE'
|
||||
export async function deleteDocumentTemplateMapping(mappingId: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${mappingId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDocumentTemplateVersionManagement(id: string) {
|
||||
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/management`);
|
||||
}
|
||||
|
||||
export async function validateDocumentTemplateVersion(id: string) {
|
||||
return apiClient<DocumentTemplateVersionValidateResponse>(`${BASE_PATH}/versions/${id}/validate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishDocumentTemplateVersion(id: string) {
|
||||
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/publish`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateDocumentTemplateVersion(id: string) {
|
||||
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/activate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function rollbackDocumentTemplateVersion(id: string) {
|
||||
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/rollback`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveDocumentTemplateVersion(id: string) {
|
||||
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/archive`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewDocumentTemplateVersion(id: string) {
|
||||
return apiClient<DocumentTemplateVersionPreviewResponse>(`${BASE_PATH}/versions/${id}/preview`);
|
||||
}
|
||||
|
||||
export async function compareDocumentTemplateVersions(
|
||||
templateId: string,
|
||||
leftVersionId: string,
|
||||
rightVersionId: string,
|
||||
) {
|
||||
return apiClient<DocumentTemplateVersionCompareResponse>(`${BASE_PATH}/${templateId}/compare`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ leftVersionId, rightVersionId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
export type DocumentTemplateLifecycleStatus =
|
||||
| 'draft'
|
||||
| 'validated'
|
||||
| 'published'
|
||||
| 'active'
|
||||
| 'archived';
|
||||
|
||||
export interface DocumentTemplateRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -15,6 +22,27 @@ export interface DocumentTemplateRecord {
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionValidationSummary {
|
||||
status: 'PASS' | 'WARNING' | 'FAIL';
|
||||
validatedAt: string | null;
|
||||
requiredFieldsMissing: string[];
|
||||
duplicateFields: string[];
|
||||
unmappedPlaceholders: string[];
|
||||
runtimeIssues: Array<{
|
||||
code: string;
|
||||
severity: 'warning' | 'error';
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionAuditSummary {
|
||||
status: 'PASS' | 'WARNING' | 'FAIL';
|
||||
auditedAt: string | null;
|
||||
runtimeVersion: string | null;
|
||||
visualRegressionStatus: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN';
|
||||
visualRegressionAt: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -24,6 +52,18 @@ export interface DocumentTemplateVersionRecord {
|
||||
schemaJson: unknown;
|
||||
previewImageUrl: string | null;
|
||||
isActive: boolean;
|
||||
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;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
@@ -80,16 +120,21 @@ export interface DocumentTemplateMappingMutationPayload {
|
||||
columns?: DocumentTemplateMappingColumnMutationPayload[];
|
||||
}
|
||||
|
||||
export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord {
|
||||
export interface DocumentTemplateMappingWithColumns
|
||||
extends DocumentTemplateMappingRecord {
|
||||
columns: DocumentTemplateTableColumnRecord[];
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionDetail extends DocumentTemplateVersionRecord {
|
||||
export interface DocumentTemplateVersionDetail
|
||||
extends DocumentTemplateVersionRecord {
|
||||
mappings: DocumentTemplateMappingWithColumns[];
|
||||
validationSummary?: DocumentTemplateVersionValidationSummary | null;
|
||||
auditSummary?: DocumentTemplateVersionAuditSummary | null;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateDetail extends DocumentTemplateRecord {
|
||||
versions: DocumentTemplateVersionDetail[];
|
||||
fileType: string;
|
||||
mappingCount: number;
|
||||
}
|
||||
|
||||
@@ -102,28 +147,7 @@ export interface DocumentTemplateListItem extends DocumentTemplateRecord {
|
||||
export interface DocumentTemplateFilters {
|
||||
documentType?: string;
|
||||
fileType?: string;
|
||||
isActive?: string;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
items: DocumentTemplateListItem[];
|
||||
}
|
||||
|
||||
export interface DocumentTemplateDetailResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
template: DocumentTemplateDetail;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionsResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
items: DocumentTemplateVersionDetail[];
|
||||
isActive?: 'true' | 'false';
|
||||
}
|
||||
|
||||
export interface DocumentTemplateMutationPayload {
|
||||
@@ -142,11 +166,15 @@ export interface DocumentTemplateVersionMutationPayload {
|
||||
schemaJson: unknown;
|
||||
previewImageUrl?: string | null;
|
||||
isActive?: boolean;
|
||||
runtimeVersion?: string | null;
|
||||
templateVariant?: string | null;
|
||||
brand?: string | null;
|
||||
cloneFromVersionId?: string | null;
|
||||
}
|
||||
|
||||
export interface ResolveTemplateParams {
|
||||
documentType: string;
|
||||
productType?: string | null;
|
||||
productType: string;
|
||||
fileType: string;
|
||||
}
|
||||
|
||||
@@ -159,3 +187,62 @@ export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateListResponse extends MutationSuccessResponse {
|
||||
time: string;
|
||||
items: DocumentTemplateListItem[];
|
||||
}
|
||||
|
||||
export interface DocumentTemplateDetailResponse extends MutationSuccessResponse {
|
||||
time: string;
|
||||
template: DocumentTemplateDetail;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionsResponse extends MutationSuccessResponse {
|
||||
time: string;
|
||||
items: DocumentTemplateVersionDetail[];
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionActionResponse
|
||||
extends MutationSuccessResponse {
|
||||
version: DocumentTemplateVersionDetail;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionValidateResponse
|
||||
extends MutationSuccessResponse {
|
||||
validation: DocumentTemplateVersionValidationSummary;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionCompareResponse
|
||||
extends MutationSuccessResponse {
|
||||
comparison: {
|
||||
leftVersionId: string;
|
||||
rightVersionId: string;
|
||||
metadataChanges: Array<{
|
||||
field: string;
|
||||
left: string | number | boolean | null;
|
||||
right: string | number | boolean | null;
|
||||
}>;
|
||||
placeholderOnlyLeft: string[];
|
||||
placeholderOnlyRight: string[];
|
||||
mappingDifferences: Array<{
|
||||
placeholderKey: string;
|
||||
difference: string;
|
||||
}>;
|
||||
runtimeCompatibility: {
|
||||
leftStatus: 'PASS' | 'WARNING' | 'FAIL';
|
||||
rightStatus: 'PASS' | 'WARNING' | 'FAIL';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface DocumentTemplateVersionPreviewResponse
|
||||
extends MutationSuccessResponse {
|
||||
preview: {
|
||||
fileName: string;
|
||||
contentType: 'application/pdf';
|
||||
base64: string;
|
||||
pageCount: number;
|
||||
source: 'audit-fixture';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@ export const PERMISSIONS = {
|
||||
crmDocumentTemplateCreate: 'crm.document_template.create',
|
||||
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
||||
crmDocumentTemplateDelete: 'crm.document_template.delete',
|
||||
crmDocumentTemplatePublish: 'crm.document_template.publish',
|
||||
crmDocumentTemplateActivate: 'crm.document_template.activate',
|
||||
crmDocumentTemplateRollback: 'crm.document_template.rollback',
|
||||
crmDocumentTemplateArchive: 'crm.document_template.archive',
|
||||
crmDocumentSequenceRead: 'crm.document_sequence.read',
|
||||
crmDocumentSequenceCreate: 'crm.document_sequence.create',
|
||||
crmDocumentSequenceUpdate: 'crm.document_sequence.update',
|
||||
@@ -653,6 +657,10 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{ key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateDelete, label: 'Delete document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplatePublish, label: 'Publish document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateActivate, label: 'Activate document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateRollback, label: 'Rollback document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateArchive, label: 'Archive document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentSequenceRead, label: 'Read document sequences' },
|
||||
{ key: PERMISSIONS.crmDocumentSequenceCreate, label: 'Create document sequences' },
|
||||
{ key: PERMISSIONS.crmDocumentSequenceUpdate, label: 'Update document sequences' },
|
||||
|
||||
Reference in New Issue
Block a user