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 {
|
import {
|
||||||
getDocumentTemplate,
|
getDocumentTemplate,
|
||||||
softDeleteDocumentTemplate,
|
softDeleteDocumentTemplate,
|
||||||
updateDocumentTemplate
|
updateDocumentTemplate,
|
||||||
} from '@/features/foundation/document-template/server/service';
|
} from '@/features/foundation/document-template/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
@@ -20,14 +20,14 @@ const documentTemplateSchema = z.object({
|
|||||||
templateName: z.string().min(1).optional(),
|
templateName: z.string().min(1).optional(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
isDefault: z.boolean().optional(),
|
isDefault: z.boolean().optional(),
|
||||||
isActive: z.boolean().optional()
|
isActive: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmDocumentTemplateRead
|
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||||
});
|
});
|
||||||
const template = await getDocumentTemplate(id, organization.id);
|
const template = await getDocumentTemplate(id, organization.id);
|
||||||
|
|
||||||
@@ -35,16 +35,21 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
success: true,
|
success: true,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Document template loaded successfully',
|
message: 'Document template loaded successfully',
|
||||||
template
|
template,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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 {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
permission: PERMISSIONS.crmDocumentTemplateUpdate,
|
||||||
});
|
});
|
||||||
const payload = documentTemplateSchema.parse(await request.json());
|
const payload = documentTemplateSchema.parse(await request.json());
|
||||||
const before = await getDocumentTemplate(id, organization.id);
|
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({
|
await auditUpdate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -64,24 +74,30 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
entityType: 'crm_document_template',
|
entityType: 'crm_document_template',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: updated
|
afterData: updated,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Document template updated successfully'
|
message: 'Document template updated successfully',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
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) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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 {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmDocumentTemplateDelete
|
permission: PERMISSIONS.crmDocumentTemplateDelete,
|
||||||
});
|
});
|
||||||
const before = await getDocumentTemplate(id, organization.id);
|
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({
|
await auditDelete({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -100,20 +120,25 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
entityType: 'crm_document_template',
|
entityType: 'crm_document_template',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: updated
|
afterData: updated,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Document template deleted successfully'
|
message: 'Document template deleted successfully',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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 { auditCreate } from '@/features/foundation/audit-log/service';
|
||||||
import {
|
import {
|
||||||
createDocumentTemplateVersion,
|
createDocumentTemplateVersion,
|
||||||
listDocumentTemplateVersions
|
listDocumentTemplateVersions,
|
||||||
} from '@/features/foundation/document-template/server/service';
|
} from '@/features/foundation/document-template/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
@@ -17,14 +17,14 @@ const versionSchema = z.object({
|
|||||||
filePath: z.string().optional().nullable(),
|
filePath: z.string().optional().nullable(),
|
||||||
schemaJson: z.unknown(),
|
schemaJson: z.unknown(),
|
||||||
previewImageUrl: z.string().optional().nullable(),
|
previewImageUrl: z.string().optional().nullable(),
|
||||||
isActive: z.boolean().optional()
|
isActive: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmDocumentTemplateRead
|
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||||
});
|
});
|
||||||
const items = await listDocumentTemplateVersions(id, organization.id);
|
const items = await listDocumentTemplateVersions(id, organization.id);
|
||||||
|
|
||||||
@@ -32,16 +32,21 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
success: true,
|
success: true,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Document template versions loaded successfully',
|
message: 'Document template versions loaded successfully',
|
||||||
items
|
items,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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 {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
permission: PERMISSIONS.crmDocumentTemplateUpdate,
|
||||||
});
|
});
|
||||||
const payload = versionSchema.parse(await request.json());
|
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({
|
await auditCreate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_document_template_version',
|
entityType: 'crm_document_template_version',
|
||||||
entityId: created.id,
|
entityId: created.id,
|
||||||
afterData: created
|
afterData: created,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Document template version created successfully'
|
message: 'Document template version created successfully',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
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) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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 { auditCreate } from '@/features/foundation/audit-log/service';
|
||||||
import {
|
import {
|
||||||
createDocumentTemplate,
|
createDocumentTemplate,
|
||||||
listDocumentTemplates
|
listDocumentTemplates,
|
||||||
} from '@/features/foundation/document-template/server/service';
|
} from '@/features/foundation/document-template/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
@@ -15,68 +15,87 @@ const documentTemplateSchema = z.object({
|
|||||||
templateName: z.string().min(1),
|
templateName: z.string().min(1),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
isDefault: z.boolean().optional(),
|
isDefault: z.boolean().optional(),
|
||||||
isActive: z.boolean().optional()
|
isActive: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmDocumentTemplateRead
|
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||||
});
|
});
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
|
const isActiveParam = searchParams.get('isActive');
|
||||||
const items = await listDocumentTemplates(organization.id, {
|
const items = await listDocumentTemplates(organization.id, {
|
||||||
documentType: searchParams.get('documentType') ?? undefined,
|
documentType: searchParams.get('documentType') ?? undefined,
|
||||||
fileType: searchParams.get('fileType') ?? undefined,
|
fileType: searchParams.get('fileType') ?? undefined,
|
||||||
isActive: searchParams.get('isActive') ?? undefined
|
isActive:
|
||||||
|
isActiveParam === 'true' || isActiveParam === 'false'
|
||||||
|
? isActiveParam
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Document templates loaded successfully',
|
message: 'Document templates loaded successfully',
|
||||||
items
|
items,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmDocumentTemplateCreate
|
permission: PERMISSIONS.crmDocumentTemplateCreate,
|
||||||
});
|
});
|
||||||
const payload = documentTemplateSchema.parse(await request.json());
|
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({
|
await auditCreate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_document_template',
|
entityType: 'crm_document_template',
|
||||||
entityId: created.id,
|
entityId: created.id,
|
||||||
afterData: created
|
afterData: created,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Document template created successfully'
|
message: 'Document template created successfully',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
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) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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 { auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
import {
|
import {
|
||||||
setDocumentTemplateVersionActive,
|
setDocumentTemplateVersionActive,
|
||||||
updateDocumentTemplateVersion
|
updateDocumentTemplateVersion,
|
||||||
} from '@/features/foundation/document-template/server/service';
|
} from '@/features/foundation/document-template/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
@@ -17,19 +17,27 @@ const versionSchema = z.object({
|
|||||||
filePath: z.string().optional().nullable(),
|
filePath: z.string().optional().nullable(),
|
||||||
schemaJson: z.unknown().optional(),
|
schemaJson: z.unknown().optional(),
|
||||||
previewImageUrl: z.string().optional().nullable(),
|
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) {
|
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
permission: PERMISSIONS.crmDocumentTemplateUpdate,
|
||||||
});
|
});
|
||||||
const payload = versionSchema.parse(await request.json());
|
const payload = versionSchema.parse(await request.json());
|
||||||
|
|
||||||
if (Object.keys(payload).length === 1 && typeof payload.isActive === 'boolean') {
|
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({
|
await auditUpdate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -37,12 +45,12 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
entityType: 'crm_document_template_version',
|
entityType: 'crm_document_template_version',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
beforeData: result.before,
|
beforeData: result.before,
|
||||||
afterData: result.after
|
afterData: result.after,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
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,
|
userId: session.user.id,
|
||||||
entityType: 'crm_document_template_version',
|
entityType: 'crm_document_template_version',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
afterData: updated
|
afterData: updated,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Document template version updated successfully'
|
message: 'Document template version updated successfully',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'Unable to update document template version' },
|
{ message: 'Unable update document template version' },
|
||||||
{ status: 500 }
|
{ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { TemplateJsonSheet } from './template-json-sheet';
|
import { TemplateJsonSheet } from './template-json-sheet';
|
||||||
|
import { TemplateVersionManagementPanel } from './template-version-management-panel';
|
||||||
import {
|
import {
|
||||||
createDocumentTemplateMappingMutation,
|
createDocumentTemplateMappingMutation,
|
||||||
createDocumentTemplateMutation,
|
createDocumentTemplateMutation,
|
||||||
@@ -527,7 +528,10 @@ function MappingDialog({
|
|||||||
function TemplateDetailCard({ templateId }: { templateId: string }) {
|
function TemplateDetailCard({ templateId }: { templateId: string }) {
|
||||||
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
|
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
|
||||||
const template = data.template;
|
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<{
|
const [mappingDialogState, setMappingDialogState] = React.useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
version?: DocumentTemplateVersionDetail;
|
version?: DocumentTemplateVersionDetail;
|
||||||
@@ -569,7 +573,7 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<Button onClick={() => setVersionDialogOpen(true)}>
|
<Button onClick={() => setVersionDialogState({ open: true, version: undefined })}>
|
||||||
<Icons.add className='mr-2 h-4 w-4' />
|
<Icons.add className='mr-2 h-4 w-4' />
|
||||||
Add Version
|
Add Version
|
||||||
</Button>
|
</Button>
|
||||||
@@ -617,7 +621,15 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
|
|||||||
filePath={version.filePath}
|
filePath={version.filePath}
|
||||||
schemaJson={version.schemaJson}
|
schemaJson={version.schemaJson}
|
||||||
/>
|
/>
|
||||||
<Button variant='outline' onClick={() => setVersionDialogOpen(true)}>
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={() =>
|
||||||
|
setVersionDialogState({
|
||||||
|
open: true,
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
Edit Version
|
Edit Version
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -634,6 +646,11 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TemplateVersionManagementPanel
|
||||||
|
templateId={template.id}
|
||||||
|
version={version}
|
||||||
|
versions={template.versions}
|
||||||
|
/>
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{version.mappings.map((mapping) => (
|
{version.mappings.map((mapping) => (
|
||||||
<div key={mapping.id} className='rounded-lg border p-4'>
|
<div key={mapping.id} className='rounded-lg border p-4'>
|
||||||
@@ -699,10 +716,12 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<VersionDialog
|
<VersionDialog
|
||||||
open={versionDialogOpen}
|
open={versionDialogState.open}
|
||||||
onOpenChange={setVersionDialogOpen}
|
onOpenChange={(open) =>
|
||||||
|
setVersionDialogState((current) => ({ ...current, open }))
|
||||||
|
}
|
||||||
templateId={template.id}
|
templateId={template.id}
|
||||||
version={undefined}
|
version={versionDialogState.version}
|
||||||
/>
|
/>
|
||||||
{mappingDialogState.open && mappingDialogState.version ? (
|
{mappingDialogState.open && mappingDialogState.version ? (
|
||||||
<MappingDialog
|
<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 { mutationOptions } from '@tanstack/react-query';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
import {
|
import {
|
||||||
|
activateDocumentTemplateVersion,
|
||||||
|
archiveDocumentTemplateVersion,
|
||||||
|
compareDocumentTemplateVersions,
|
||||||
createDocumentTemplate,
|
createDocumentTemplate,
|
||||||
createDocumentTemplateMapping,
|
createDocumentTemplateMapping,
|
||||||
createDocumentTemplateVersion,
|
createDocumentTemplateVersion,
|
||||||
deleteDocumentTemplate,
|
deleteDocumentTemplate,
|
||||||
deleteDocumentTemplateMapping,
|
deleteDocumentTemplateMapping,
|
||||||
|
previewDocumentTemplateVersion,
|
||||||
|
publishDocumentTemplateVersion,
|
||||||
|
rollbackDocumentTemplateVersion,
|
||||||
setDocumentTemplateVersionActive,
|
setDocumentTemplateVersionActive,
|
||||||
updateDocumentTemplate,
|
updateDocumentTemplate,
|
||||||
updateDocumentTemplateMapping,
|
updateDocumentTemplateMapping,
|
||||||
updateDocumentTemplateVersion
|
updateDocumentTemplateVersion,
|
||||||
|
validateDocumentTemplateVersion,
|
||||||
} from './service';
|
} from './service';
|
||||||
import { documentTemplateKeys } from './queries';
|
import { documentTemplateKeys } from './queries';
|
||||||
import type {
|
import type {
|
||||||
DocumentTemplateMappingMutationPayload,
|
DocumentTemplateMappingMutationPayload,
|
||||||
DocumentTemplateMutationPayload,
|
DocumentTemplateMutationPayload,
|
||||||
DocumentTemplateVersionMutationPayload
|
DocumentTemplateVersionMutationPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
async function invalidateTemplateLists() {
|
async function invalidateTemplateLists() {
|
||||||
@@ -25,10 +32,16 @@ async function invalidateTemplateLists() {
|
|||||||
async function invalidateTemplateDetail(id: string) {
|
async function invalidateTemplateDetail(id: string) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(id) }),
|
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({
|
export const createDocumentTemplateMutation = mutationOptions({
|
||||||
mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data),
|
mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data),
|
||||||
onSettled: async (_data, error) => {
|
onSettled: async (_data, error) => {
|
||||||
@@ -120,8 +133,7 @@ export const createDocumentTemplateMappingMutation = mutationOptions({
|
|||||||
versionId: string;
|
versionId: string;
|
||||||
values: DocumentTemplateMappingMutationPayload;
|
values: DocumentTemplateMappingMutationPayload;
|
||||||
}) => {
|
}) => {
|
||||||
void templateId;
|
return createDocumentTemplateMapping(templateId, versionId, values);
|
||||||
return createDocumentTemplateMapping(versionId, values);
|
|
||||||
},
|
},
|
||||||
onSettled: async (_data, error, variables) => {
|
onSettled: async (_data, error, variables) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
@@ -159,5 +171,101 @@ export const deleteDocumentTemplateMappingMutation = mutationOptions({
|
|||||||
if (!error) {
|
if (!error) {
|
||||||
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
|
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 { queryOptions } from '@tanstack/react-query';
|
||||||
import { getDocumentTemplateById, getDocumentTemplates, getDocumentTemplateVersions } from './service';
|
import {
|
||||||
|
getDocumentTemplateById,
|
||||||
|
getDocumentTemplates,
|
||||||
|
getDocumentTemplateVersionManagement,
|
||||||
|
getDocumentTemplateVersions,
|
||||||
|
} from './service';
|
||||||
import type { DocumentTemplateFilters } from './types';
|
import type { DocumentTemplateFilters } from './types';
|
||||||
|
|
||||||
export const documentTemplateKeys = {
|
export const documentTemplateKeys = {
|
||||||
@@ -10,7 +15,10 @@ export const documentTemplateKeys = {
|
|||||||
details: () => [...documentTemplateKeys.all, 'detail'] as const,
|
details: () => [...documentTemplateKeys.all, 'detail'] as const,
|
||||||
detail: (id: string) => [...documentTemplateKeys.details(), id] as const,
|
detail: (id: string) => [...documentTemplateKeys.details(), id] as const,
|
||||||
versionsRoot: () => [...documentTemplateKeys.all, 'versions'] 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 = {}) =>
|
export const documentTemplatesQueryOptions = (filters: DocumentTemplateFilters = {}) =>
|
||||||
@@ -28,5 +36,11 @@ export const documentTemplateByIdOptions = (id: string) =>
|
|||||||
export const documentTemplateVersionsQueryOptions = (id: string) =>
|
export const documentTemplateVersionsQueryOptions = (id: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: documentTemplateKeys.versions(id),
|
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 {
|
import type {
|
||||||
DocumentTemplateDetail,
|
DocumentTemplateDetail,
|
||||||
DocumentTemplateFilters,
|
DocumentTemplateFilters,
|
||||||
|
DocumentTemplateLifecycleStatus,
|
||||||
DocumentTemplateListItem,
|
DocumentTemplateListItem,
|
||||||
DocumentTemplateMappingMutationPayload,
|
DocumentTemplateMappingMutationPayload,
|
||||||
DocumentTemplateMappingRecord,
|
DocumentTemplateMappingRecord,
|
||||||
@@ -29,6 +30,62 @@ import type {
|
|||||||
ResolvedDocumentTemplate
|
ResolvedDocumentTemplate
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5-template-management';
|
||||||
|
|
||||||
|
type DocumentTemplateManagementMetadata = {
|
||||||
|
lifecycleStatus?: DocumentTemplateLifecycleStatus;
|
||||||
|
runtimeVersion?: string | null;
|
||||||
|
templateVariant?: string | null;
|
||||||
|
brand?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
publishedBy?: string | null;
|
||||||
|
activatedAt?: string | null;
|
||||||
|
activatedBy?: string | null;
|
||||||
|
archivedAt?: string | null;
|
||||||
|
archivedBy?: string | null;
|
||||||
|
previousVersionId?: string | null;
|
||||||
|
nextVersionId?: string | null;
|
||||||
|
validatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTemplateSchemaRecord(schemaJson: unknown): Record<string, unknown> {
|
||||||
|
return schemaJson && typeof schemaJson === 'object'
|
||||||
|
? { ...(schemaJson as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManagementMetadata(schemaJson: unknown): DocumentTemplateManagementMetadata {
|
||||||
|
const schema = getTemplateSchemaRecord(schemaJson);
|
||||||
|
const metadata = schema.__templateManagement;
|
||||||
|
return metadata && typeof metadata === 'object'
|
||||||
|
? { ...(metadata as DocumentTemplateManagementMetadata) }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withManagementMetadata(
|
||||||
|
schemaJson: unknown,
|
||||||
|
metadata: DocumentTemplateManagementMetadata
|
||||||
|
): unknown {
|
||||||
|
return {
|
||||||
|
...getTemplateSchemaRecord(schemaJson),
|
||||||
|
__templateManagement: {
|
||||||
|
...getManagementMetadata(schemaJson),
|
||||||
|
...metadata
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferLifecycleStatus(args: {
|
||||||
|
isActive: boolean;
|
||||||
|
metadata: DocumentTemplateManagementMetadata;
|
||||||
|
}): DocumentTemplateLifecycleStatus {
|
||||||
|
if (args.isActive) {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
return args.metadata.lifecycleStatus ?? 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): DocumentTemplateRecord {
|
function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): DocumentTemplateRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -51,6 +108,8 @@ function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): Docum
|
|||||||
function mapVersionRecord(
|
function mapVersionRecord(
|
||||||
row: typeof crmDocumentTemplateVersions.$inferSelect
|
row: typeof crmDocumentTemplateVersions.$inferSelect
|
||||||
): DocumentTemplateVersionRecord {
|
): DocumentTemplateVersionRecord {
|
||||||
|
const metadata = getManagementMetadata(row.schemaJson);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
@@ -60,6 +119,21 @@ function mapVersionRecord(
|
|||||||
schemaJson: row.schemaJson,
|
schemaJson: row.schemaJson,
|
||||||
previewImageUrl: row.previewImageUrl,
|
previewImageUrl: row.previewImageUrl,
|
||||||
isActive: row.isActive,
|
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(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||||
|
|||||||
@@ -5,20 +5,22 @@ import type {
|
|||||||
DocumentTemplateListResponse,
|
DocumentTemplateListResponse,
|
||||||
DocumentTemplateMappingMutationPayload,
|
DocumentTemplateMappingMutationPayload,
|
||||||
DocumentTemplateMutationPayload,
|
DocumentTemplateMutationPayload,
|
||||||
|
DocumentTemplateVersionActionResponse,
|
||||||
|
DocumentTemplateVersionCompareResponse,
|
||||||
DocumentTemplateVersionMutationPayload,
|
DocumentTemplateVersionMutationPayload,
|
||||||
|
DocumentTemplateVersionPreviewResponse,
|
||||||
|
DocumentTemplateVersionValidateResponse,
|
||||||
DocumentTemplateVersionsResponse,
|
DocumentTemplateVersionsResponse,
|
||||||
MutationSuccessResponse
|
MutationSuccessResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const BASE_PATH = '/crm/settings/document-templates';
|
const BASE_PATH = '/crm/settings/document-templates';
|
||||||
|
|
||||||
export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) {
|
export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
if (filters.documentType) searchParams.set('documentType', filters.documentType);
|
if (filters.documentType) searchParams.set('documentType', filters.documentType);
|
||||||
if (filters.fileType) searchParams.set('fileType', filters.fileType);
|
if (filters.fileType) searchParams.set('fileType', filters.fileType);
|
||||||
if (filters.isActive) searchParams.set('isActive', filters.isActive);
|
if (filters.isActive) searchParams.set('isActive', filters.isActive);
|
||||||
|
|
||||||
const query = searchParams.toString();
|
const query = searchParams.toString();
|
||||||
return apiClient<DocumentTemplateListResponse>(`${BASE_PATH}${query ? `?${query}` : ''}`);
|
return apiClient<DocumentTemplateListResponse>(`${BASE_PATH}${query ? `?${query}` : ''}`);
|
||||||
}
|
}
|
||||||
@@ -30,23 +32,23 @@ export async function getDocumentTemplateById(id: string) {
|
|||||||
export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) {
|
export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) {
|
||||||
return apiClient<MutationSuccessResponse>(BASE_PATH, {
|
return apiClient<MutationSuccessResponse>(BASE_PATH, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDocumentTemplate(
|
export async function updateDocumentTemplate(
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<DocumentTemplateMutationPayload>
|
data: Partial<DocumentTemplateMutationPayload>,
|
||||||
) {
|
) {
|
||||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDocumentTemplate(id: string) {
|
export async function deleteDocumentTemplate(id: string) {
|
||||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,53 +58,104 @@ export async function getDocumentTemplateVersions(id: string) {
|
|||||||
|
|
||||||
export async function createDocumentTemplateVersion(
|
export async function createDocumentTemplateVersion(
|
||||||
id: string,
|
id: string,
|
||||||
data: DocumentTemplateVersionMutationPayload
|
data: DocumentTemplateVersionMutationPayload,
|
||||||
) {
|
) {
|
||||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}/versions`, {
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}/versions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDocumentTemplateVersion(
|
export async function updateDocumentTemplateVersion(
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<DocumentTemplateVersionMutationPayload>
|
data: Partial<DocumentTemplateVersionMutationPayload>,
|
||||||
) {
|
) {
|
||||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setDocumentTemplateVersionActive(id: string, isActive: boolean) {
|
export async function setDocumentTemplateVersionActive(id: string, isActive: boolean) {
|
||||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ isActive })
|
body: JSON.stringify({ isActive }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createDocumentTemplateMapping(
|
export async function createDocumentTemplateMapping(
|
||||||
|
templateId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
data: DocumentTemplateMappingMutationPayload
|
data: DocumentTemplateMappingMutationPayload,
|
||||||
) {
|
) {
|
||||||
|
void templateId;
|
||||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${versionId}/mappings`, {
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${versionId}/mappings`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDocumentTemplateMapping(
|
export async function updateDocumentTemplateMapping(
|
||||||
id: string,
|
mappingId: string,
|
||||||
data: Partial<DocumentTemplateMappingMutationPayload>
|
data: Partial<DocumentTemplateMappingMutationPayload>,
|
||||||
) {
|
) {
|
||||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${mappingId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDocumentTemplateMapping(id: string) {
|
export async function deleteDocumentTemplateMapping(mappingId: string) {
|
||||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${mappingId}`, {
|
||||||
method: 'DELETE'
|
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 {
|
export interface DocumentTemplateRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -15,6 +22,27 @@ export interface DocumentTemplateRecord {
|
|||||||
updatedBy: string;
|
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 {
|
export interface DocumentTemplateVersionRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -24,6 +52,18 @@ export interface DocumentTemplateVersionRecord {
|
|||||||
schemaJson: unknown;
|
schemaJson: unknown;
|
||||||
previewImageUrl: string | null;
|
previewImageUrl: string | null;
|
||||||
isActive: boolean;
|
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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
@@ -80,16 +120,21 @@ export interface DocumentTemplateMappingMutationPayload {
|
|||||||
columns?: DocumentTemplateMappingColumnMutationPayload[];
|
columns?: DocumentTemplateMappingColumnMutationPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord {
|
export interface DocumentTemplateMappingWithColumns
|
||||||
|
extends DocumentTemplateMappingRecord {
|
||||||
columns: DocumentTemplateTableColumnRecord[];
|
columns: DocumentTemplateTableColumnRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentTemplateVersionDetail extends DocumentTemplateVersionRecord {
|
export interface DocumentTemplateVersionDetail
|
||||||
|
extends DocumentTemplateVersionRecord {
|
||||||
mappings: DocumentTemplateMappingWithColumns[];
|
mappings: DocumentTemplateMappingWithColumns[];
|
||||||
|
validationSummary?: DocumentTemplateVersionValidationSummary | null;
|
||||||
|
auditSummary?: DocumentTemplateVersionAuditSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentTemplateDetail extends DocumentTemplateRecord {
|
export interface DocumentTemplateDetail extends DocumentTemplateRecord {
|
||||||
versions: DocumentTemplateVersionDetail[];
|
versions: DocumentTemplateVersionDetail[];
|
||||||
|
fileType: string;
|
||||||
mappingCount: number;
|
mappingCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,28 +147,7 @@ export interface DocumentTemplateListItem extends DocumentTemplateRecord {
|
|||||||
export interface DocumentTemplateFilters {
|
export interface DocumentTemplateFilters {
|
||||||
documentType?: string;
|
documentType?: string;
|
||||||
fileType?: string;
|
fileType?: string;
|
||||||
isActive?: string;
|
isActive?: 'true' | 'false';
|
||||||
}
|
|
||||||
|
|
||||||
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[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentTemplateMutationPayload {
|
export interface DocumentTemplateMutationPayload {
|
||||||
@@ -142,11 +166,15 @@ export interface DocumentTemplateVersionMutationPayload {
|
|||||||
schemaJson: unknown;
|
schemaJson: unknown;
|
||||||
previewImageUrl?: string | null;
|
previewImageUrl?: string | null;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
runtimeVersion?: string | null;
|
||||||
|
templateVariant?: string | null;
|
||||||
|
brand?: string | null;
|
||||||
|
cloneFromVersionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolveTemplateParams {
|
export interface ResolveTemplateParams {
|
||||||
documentType: string;
|
documentType: string;
|
||||||
productType?: string | null;
|
productType: string;
|
||||||
fileType: string;
|
fileType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,3 +187,62 @@ export interface MutationSuccessResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
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',
|
crmDocumentTemplateCreate: 'crm.document_template.create',
|
||||||
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
||||||
crmDocumentTemplateDelete: 'crm.document_template.delete',
|
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',
|
crmDocumentSequenceRead: 'crm.document_sequence.read',
|
||||||
crmDocumentSequenceCreate: 'crm.document_sequence.create',
|
crmDocumentSequenceCreate: 'crm.document_sequence.create',
|
||||||
crmDocumentSequenceUpdate: 'crm.document_sequence.update',
|
crmDocumentSequenceUpdate: 'crm.document_sequence.update',
|
||||||
@@ -653,6 +657,10 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' },
|
{ key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' },
|
||||||
{ key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' },
|
{ key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' },
|
||||||
{ key: PERMISSIONS.crmDocumentTemplateDelete, label: 'Delete 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.crmDocumentSequenceRead, label: 'Read document sequences' },
|
||||||
{ key: PERMISSIONS.crmDocumentSequenceCreate, label: 'Create document sequences' },
|
{ key: PERMISSIONS.crmDocumentSequenceCreate, label: 'Create document sequences' },
|
||||||
{ key: PERMISSIONS.crmDocumentSequenceUpdate, label: 'Update document sequences' },
|
{ key: PERMISSIONS.crmDocumentSequenceUpdate, label: 'Update document sequences' },
|
||||||
|
|||||||
Reference in New Issue
Block a user