From 4570495a77bf7feacf707e570b8c105c8acbc532 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 29 Jun 2026 23:05:01 +0700 Subject: [PATCH] task-p.5 --- .../implementation/task-p.5-implementation.md | 221 ++++++ .../task-p.5-verification-report.md | 135 ++++ plans/task-p.5.md | 513 +++++++++++++ .../api/crm/document-templates/[id]/route.ts | 57 +- .../document-templates/[id]/versions/route.ts | 38 +- src/app/api/crm/document-templates/route.ts | 43 +- .../document-templates/[id]/compare/route.ts | 53 ++ .../versions/[id]/activate/route.ts | 51 ++ .../versions/[id]/archive/route.ts | 51 ++ .../versions/[id]/management/route.ts | 37 + .../versions/[id]/preview/route.ts | 40 ++ .../versions/[id]/publish/route.ts | 51 ++ .../versions/[id]/rollback/route.ts | 51 ++ .../document-templates/versions/[id]/route.ts | 36 +- .../versions/[id]/validate/route.ts | 50 ++ .../components/template-settings.tsx | 31 +- .../template-version-management-panel.tsx | 472 ++++++++++++ .../foundation/document-template/mutations.ts | 120 +++- .../foundation/document-template/queries.ts | 20 +- .../server/management-service.ts | 674 ++++++++++++++++++ .../document-template/server/service.ts | 74 ++ .../foundation/document-template/service.ts | 95 ++- .../foundation/document-template/types.ts | 137 +++- src/lib/auth/rbac.ts | 8 + 24 files changed, 2944 insertions(+), 114 deletions(-) create mode 100644 docs/implementation/task-p.5-implementation.md create mode 100644 docs/implementation/task-p.5-verification-report.md create mode 100644 plans/task-p.5.md create mode 100644 src/app/api/crm/settings/document-templates/[id]/compare/route.ts create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/activate/route.ts create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/archive/route.ts create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/management/route.ts create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/preview/route.ts create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/publish/route.ts create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/rollback/route.ts create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/validate/route.ts create mode 100644 src/features/foundation/document-template/components/template-version-management-panel.tsx create mode 100644 src/features/foundation/document-template/server/management-service.ts diff --git a/docs/implementation/task-p.5-implementation.md b/docs/implementation/task-p.5-implementation.md new file mode 100644 index 0000000..73e7653 --- /dev/null +++ b/docs/implementation/task-p.5-implementation.md @@ -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. diff --git a/docs/implementation/task-p.5-verification-report.md b/docs/implementation/task-p.5-verification-report.md new file mode 100644 index 0000000..0940a09 --- /dev/null +++ b/docs/implementation/task-p.5-verification-report.md @@ -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 diff --git a/plans/task-p.5.md b/plans/task-p.5.md new file mode 100644 index 0000000..357313e --- /dev/null +++ b/plans/task-p.5.md @@ -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. diff --git a/src/app/api/crm/document-templates/[id]/route.ts b/src/app/api/crm/document-templates/[id]/route.ts index aac790a..5577623 100644 --- a/src/app/api/crm/document-templates/[id]/route.ts +++ b/src/app/api/crm/document-templates/[id]/route.ts @@ -4,7 +4,7 @@ import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/servic import { getDocumentTemplate, softDeleteDocumentTemplate, - updateDocumentTemplate + updateDocumentTemplate, } from '@/features/foundation/document-template/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -20,14 +20,14 @@ const documentTemplateSchema = z.object({ templateName: z.string().min(1).optional(), description: z.string().optional().nullable(), isDefault: z.boolean().optional(), - isActive: z.boolean().optional() + isActive: z.boolean().optional(), }); export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmDocumentTemplateRead + permission: PERMISSIONS.crmDocumentTemplateRead, }); const template = await getDocumentTemplate(id, organization.id); @@ -35,16 +35,21 @@ export async function GET(_request: NextRequest, { params }: Params) { success: true, time: new Date().toISOString(), message: 'Document template loaded successfully', - template + template, }); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); } + if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to load document template' }, { status: 500 }); + + return NextResponse.json( + { message: 'Unable load document template' }, + { status: 500 }, + ); } } @@ -52,11 +57,16 @@ export async function PATCH(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmDocumentTemplateUpdate + permission: PERMISSIONS.crmDocumentTemplateUpdate, }); const payload = documentTemplateSchema.parse(await request.json()); const before = await getDocumentTemplate(id, organization.id); - const updated = await updateDocumentTemplate(id, organization.id, session.user.id, payload); + const updated = await updateDocumentTemplate( + id, + organization.id, + session.user.id, + payload, + ); await auditUpdate({ organizationId: organization.id, @@ -64,24 +74,30 @@ export async function PATCH(request: NextRequest, { params }: Params) { entityType: 'crm_document_template', entityId: id, beforeData: before, - afterData: updated + afterData: updated, }); return NextResponse.json({ success: true, - message: 'Document template updated successfully' + message: 'Document template updated successfully', }); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); } + if (error instanceof z.ZodError) { - return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 }); + return NextResponse.json({ message: error.flatten() }, { status: 400 }); } + if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to update document template' }, { status: 500 }); + + return NextResponse.json( + { message: 'Unable update document template' }, + { status: 500 }, + ); } } @@ -89,10 +105,14 @@ export async function DELETE(_request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmDocumentTemplateDelete + permission: PERMISSIONS.crmDocumentTemplateDelete, }); const before = await getDocumentTemplate(id, organization.id); - const updated = await softDeleteDocumentTemplate(id, organization.id, session.user.id); + const updated = await softDeleteDocumentTemplate( + id, + organization.id, + session.user.id, + ); await auditDelete({ organizationId: organization.id, @@ -100,20 +120,25 @@ export async function DELETE(_request: NextRequest, { params }: Params) { entityType: 'crm_document_template', entityId: id, beforeData: before, - afterData: updated + afterData: updated, }); return NextResponse.json({ success: true, - message: 'Document template deleted successfully' + message: 'Document template deleted successfully', }); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); } + if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to delete document template' }, { status: 500 }); + + return NextResponse.json( + { message: 'Unable delete document template' }, + { status: 500 }, + ); } } diff --git a/src/app/api/crm/document-templates/[id]/versions/route.ts b/src/app/api/crm/document-templates/[id]/versions/route.ts index b8db365..8024230 100644 --- a/src/app/api/crm/document-templates/[id]/versions/route.ts +++ b/src/app/api/crm/document-templates/[id]/versions/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { auditCreate } from '@/features/foundation/audit-log/service'; import { createDocumentTemplateVersion, - listDocumentTemplateVersions + listDocumentTemplateVersions, } from '@/features/foundation/document-template/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -17,14 +17,14 @@ const versionSchema = z.object({ filePath: z.string().optional().nullable(), schemaJson: z.unknown(), previewImageUrl: z.string().optional().nullable(), - isActive: z.boolean().optional() + isActive: z.boolean().optional(), }); export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmDocumentTemplateRead + permission: PERMISSIONS.crmDocumentTemplateRead, }); const items = await listDocumentTemplateVersions(id, organization.id); @@ -32,16 +32,21 @@ export async function GET(_request: NextRequest, { params }: Params) { success: true, time: new Date().toISOString(), message: 'Document template versions loaded successfully', - items + items, }); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); } + if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to load document template versions' }, { status: 500 }); + + return NextResponse.json( + { message: 'Unable load document template versions' }, + { status: 500 }, + ); } } @@ -49,33 +54,44 @@ export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmDocumentTemplateUpdate + permission: PERMISSIONS.crmDocumentTemplateUpdate, }); const payload = versionSchema.parse(await request.json()); - const created = await createDocumentTemplateVersion(id, organization.id, session.user.id, payload); + const created = await createDocumentTemplateVersion( + id, + organization.id, + session.user.id, + payload, + ); await auditCreate({ organizationId: organization.id, userId: session.user.id, entityType: 'crm_document_template_version', entityId: created.id, - afterData: created + afterData: created, }); return NextResponse.json({ success: true, - message: 'Document template version created successfully' + message: 'Document template version created successfully', }); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); } + if (error instanceof z.ZodError) { - return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 }); + return NextResponse.json({ message: error.flatten() }, { status: 400 }); } + if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to create document template version' }, { status: 500 }); + + return NextResponse.json( + { message: 'Unable create document template version' }, + { status: 500 }, + ); } } diff --git a/src/app/api/crm/document-templates/route.ts b/src/app/api/crm/document-templates/route.ts index 0d3c429..9e4e51f 100644 --- a/src/app/api/crm/document-templates/route.ts +++ b/src/app/api/crm/document-templates/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { auditCreate } from '@/features/foundation/audit-log/service'; import { createDocumentTemplate, - listDocumentTemplates + listDocumentTemplates, } from '@/features/foundation/document-template/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -15,68 +15,87 @@ const documentTemplateSchema = z.object({ templateName: z.string().min(1), description: z.string().optional().nullable(), isDefault: z.boolean().optional(), - isActive: z.boolean().optional() + isActive: z.boolean().optional(), }); export async function GET(request: NextRequest) { try { const { organization } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmDocumentTemplateRead + permission: PERMISSIONS.crmDocumentTemplateRead, }); const { searchParams } = request.nextUrl; + const isActiveParam = searchParams.get('isActive'); const items = await listDocumentTemplates(organization.id, { documentType: searchParams.get('documentType') ?? undefined, fileType: searchParams.get('fileType') ?? undefined, - isActive: searchParams.get('isActive') ?? undefined + isActive: + isActiveParam === 'true' || isActiveParam === 'false' + ? isActiveParam + : undefined, }); return NextResponse.json({ success: true, time: new Date().toISOString(), message: 'Document templates loaded successfully', - items + items, }); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); } + if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to load document templates' }, { status: 500 }); + + return NextResponse.json( + { message: 'Unable load document templates' }, + { status: 500 }, + ); } } export async function POST(request: NextRequest) { try { const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmDocumentTemplateCreate + permission: PERMISSIONS.crmDocumentTemplateCreate, }); const payload = documentTemplateSchema.parse(await request.json()); - const created = await createDocumentTemplate(organization.id, session.user.id, payload); + const created = await createDocumentTemplate( + organization.id, + session.user.id, + payload, + ); await auditCreate({ organizationId: organization.id, userId: session.user.id, entityType: 'crm_document_template', entityId: created.id, - afterData: created + afterData: created, }); return NextResponse.json({ success: true, - message: 'Document template created successfully' + message: 'Document template created successfully', }); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); } + if (error instanceof z.ZodError) { - return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 }); + return NextResponse.json({ message: error.flatten() }, { status: 400 }); } + if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to create document template' }, { status: 500 }); + + return NextResponse.json( + { message: 'Unable create document template' }, + { status: 500 }, + ); } } diff --git a/src/app/api/crm/settings/document-templates/[id]/compare/route.ts b/src/app/api/crm/settings/document-templates/[id]/compare/route.ts new file mode 100644 index 0000000..f6b68e3 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/[id]/compare/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/activate/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/activate/route.ts new file mode 100644 index 0000000..1416cc2 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/activate/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/archive/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/archive/route.ts new file mode 100644 index 0000000..1bb8bf2 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/archive/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/management/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/management/route.ts new file mode 100644 index 0000000..0806eaa --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/management/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/preview/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/preview/route.ts new file mode 100644 index 0000000..9b94ccc --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/preview/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/publish/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/publish/route.ts new file mode 100644 index 0000000..4d1b538 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/publish/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/rollback/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/rollback/route.ts new file mode 100644 index 0000000..c2c462e --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/rollback/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/route.ts index 353c671..5ecb01b 100644 --- a/src/app/api/crm/settings/document-templates/versions/[id]/route.ts +++ b/src/app/api/crm/settings/document-templates/versions/[id]/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { auditUpdate } from '@/features/foundation/audit-log/service'; import { setDocumentTemplateVersionActive, - updateDocumentTemplateVersion + updateDocumentTemplateVersion, } from '@/features/foundation/document-template/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -17,19 +17,27 @@ const versionSchema = z.object({ filePath: z.string().optional().nullable(), schemaJson: z.unknown().optional(), previewImageUrl: z.string().optional().nullable(), - isActive: z.boolean().optional() + isActive: z.boolean().optional(), + runtimeVersion: z.string().optional().nullable(), + templateVariant: z.string().optional().nullable(), + brand: z.string().optional().nullable(), + cloneFromVersionId: z.string().optional().nullable(), }); export async function PATCH(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmDocumentTemplateUpdate + permission: PERMISSIONS.crmDocumentTemplateUpdate, }); const payload = versionSchema.parse(await request.json()); if (Object.keys(payload).length === 1 && typeof payload.isActive === 'boolean') { - const result = await setDocumentTemplateVersionActive(id, organization.id, payload.isActive); + const result = await setDocumentTemplateVersionActive( + id, + organization.id, + payload.isActive, + ); await auditUpdate({ organizationId: organization.id, @@ -37,12 +45,12 @@ export async function PATCH(request: NextRequest, { params }: Params) { entityType: 'crm_document_template_version', entityId: id, beforeData: result.before, - afterData: result.after + afterData: result.after, }); return NextResponse.json({ success: true, - message: 'Document template version status updated successfully' + message: 'Document template version status updated successfully', }); } @@ -53,29 +61,29 @@ export async function PATCH(request: NextRequest, { params }: Params) { userId: session.user.id, entityType: 'crm_document_template_version', entityId: id, - afterData: updated + afterData: updated, }); return NextResponse.json({ success: true, - message: 'Document template version updated successfully' + message: 'Document template version updated successfully', }); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); } + if (error instanceof z.ZodError) { - return NextResponse.json( - { message: error.issues[0]?.message ?? 'Invalid payload' }, - { status: 400 } - ); + return NextResponse.json({ message: error.flatten() }, { status: 400 }); } + if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } + return NextResponse.json( - { message: 'Unable to update document template version' }, - { status: 500 } + { message: 'Unable update document template version' }, + { status: 500 }, ); } } diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/validate/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/validate/route.ts new file mode 100644 index 0000000..4107dd0 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/validate/route.ts @@ -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 }, + ); + } +} diff --git a/src/features/foundation/document-template/components/template-settings.tsx b/src/features/foundation/document-template/components/template-settings.tsx index 41a763c..f475807 100644 --- a/src/features/foundation/document-template/components/template-settings.tsx +++ b/src/features/foundation/document-template/components/template-settings.tsx @@ -19,6 +19,7 @@ import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { TemplateJsonSheet } from './template-json-sheet'; +import { TemplateVersionManagementPanel } from './template-version-management-panel'; import { createDocumentTemplateMappingMutation, createDocumentTemplateMutation, @@ -527,7 +528,10 @@ function MappingDialog({ function TemplateDetailCard({ templateId }: { templateId: string }) { const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId)); const template = data.template; - const [versionDialogOpen, setVersionDialogOpen] = React.useState(false); + const [versionDialogState, setVersionDialogState] = React.useState<{ + open: boolean; + version?: DocumentTemplateVersionDetail; + }>({ open: false }); const [mappingDialogState, setMappingDialogState] = React.useState<{ open: boolean; version?: DocumentTemplateVersionDetail; @@ -569,7 +573,7 @@ function TemplateDetailCard({ templateId }: { templateId: string }) { ))} - @@ -617,7 +621,15 @@ function TemplateDetailCard({ templateId }: { templateId: string }) { filePath={version.filePath} schemaJson={version.schemaJson} /> - + + + + + ); +} + +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
No audit summary available.
; + } + + return ( +
+ {auditSummary.status}} + /> + + {auditSummary.visualRegressionStatus} + + } + /> + + +
+ ); + } + + return ( + <> +
+
+
+
Version Lifecycle Management
+
+ + {managedVersion.lifecycleStatus ?? 'draft'} + + + {managedVersion.isActive ? 'Active Runtime' : 'Inactive Runtime'} + + {validation ? ( + + Validation {validation.status} + + ) : null} +
+
+
+ + + + + + + +
+
+ +
+ + + + + + + + +
+ +
+
Validation Summary
+ {validation ? ( +
+
+ {validation.status}} + /> + + + +
+ + + + +
+ ) : ( +
+ Validation has not been run for this version yet. +
+ )} +
+ +
+
Audit Summary
+ {renderAuditSummary(audit)} +
+
+ + + + ); +} diff --git a/src/features/foundation/document-template/mutations.ts b/src/features/foundation/document-template/mutations.ts index 958c28f..deda8e7 100644 --- a/src/features/foundation/document-template/mutations.ts +++ b/src/features/foundation/document-template/mutations.ts @@ -1,21 +1,28 @@ import { mutationOptions } from '@tanstack/react-query'; import { getQueryClient } from '@/lib/query-client'; import { + activateDocumentTemplateVersion, + archiveDocumentTemplateVersion, + compareDocumentTemplateVersions, createDocumentTemplate, createDocumentTemplateMapping, createDocumentTemplateVersion, deleteDocumentTemplate, deleteDocumentTemplateMapping, + previewDocumentTemplateVersion, + publishDocumentTemplateVersion, + rollbackDocumentTemplateVersion, setDocumentTemplateVersionActive, updateDocumentTemplate, updateDocumentTemplateMapping, - updateDocumentTemplateVersion + updateDocumentTemplateVersion, + validateDocumentTemplateVersion, } from './service'; import { documentTemplateKeys } from './queries'; import type { DocumentTemplateMappingMutationPayload, DocumentTemplateMutationPayload, - DocumentTemplateVersionMutationPayload + DocumentTemplateVersionMutationPayload, } from './types'; async function invalidateTemplateLists() { @@ -25,10 +32,16 @@ async function invalidateTemplateLists() { async function invalidateTemplateDetail(id: string) { await Promise.all([ getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(id) }), - getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) }) + getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) }), ]); } +async function invalidateVersionManagement(versionId: string) { + await getQueryClient().invalidateQueries({ + queryKey: documentTemplateKeys.management(versionId), + }); +} + export const createDocumentTemplateMutation = mutationOptions({ mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data), onSettled: async (_data, error) => { @@ -120,8 +133,7 @@ export const createDocumentTemplateMappingMutation = mutationOptions({ versionId: string; values: DocumentTemplateMappingMutationPayload; }) => { - void templateId; - return createDocumentTemplateMapping(versionId, values); + return createDocumentTemplateMapping(templateId, versionId, values); }, onSettled: async (_data, error, variables) => { if (!error) { @@ -159,5 +171,101 @@ export const deleteDocumentTemplateMappingMutation = mutationOptions({ if (!error) { await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]); } - } + }, +}); + +export const validateDocumentTemplateVersionMutation = mutationOptions({ + mutationFn: (versionId: string) => validateDocumentTemplateVersion(versionId), +}); + +export const publishDocumentTemplateVersionMutation = mutationOptions({ + mutationFn: ({ + templateId, + versionId, + }: { + templateId: string; + versionId: string; + }) => publishDocumentTemplateVersion(versionId), + onSettled: async (_data, error, variables) => { + if (!error) { + await Promise.all([ + invalidateTemplateLists(), + invalidateTemplateDetail(variables.templateId), + invalidateVersionManagement(variables.versionId), + ]); + } + }, +}); + +export const activateDocumentTemplateVersionMutation = mutationOptions({ + mutationFn: ({ + templateId, + versionId, + }: { + templateId: string; + versionId: string; + }) => activateDocumentTemplateVersion(versionId), + onSettled: async (_data, error, variables) => { + if (!error) { + await Promise.all([ + invalidateTemplateLists(), + invalidateTemplateDetail(variables.templateId), + invalidateVersionManagement(variables.versionId), + ]); + } + }, +}); + +export const rollbackDocumentTemplateVersionMutation = mutationOptions({ + mutationFn: ({ + templateId, + versionId, + }: { + templateId: string; + versionId: string; + }) => rollbackDocumentTemplateVersion(versionId), + onSettled: async (_data, error, variables) => { + if (!error) { + await Promise.all([ + invalidateTemplateLists(), + invalidateTemplateDetail(variables.templateId), + invalidateVersionManagement(variables.versionId), + ]); + } + }, +}); + +export const archiveDocumentTemplateVersionMutation = mutationOptions({ + mutationFn: ({ + templateId, + versionId, + }: { + templateId: string; + versionId: string; + }) => archiveDocumentTemplateVersion(versionId), + onSettled: async (_data, error, variables) => { + if (!error) { + await Promise.all([ + invalidateTemplateLists(), + invalidateTemplateDetail(variables.templateId), + invalidateVersionManagement(variables.versionId), + ]); + } + }, +}); + +export const previewDocumentTemplateVersionMutation = mutationOptions({ + mutationFn: (versionId: string) => previewDocumentTemplateVersion(versionId), +}); + +export const compareDocumentTemplateVersionsMutation = mutationOptions({ + mutationFn: ({ + templateId, + leftVersionId, + rightVersionId, + }: { + templateId: string; + leftVersionId: string; + rightVersionId: string; + }) => compareDocumentTemplateVersions(templateId, leftVersionId, rightVersionId), }); diff --git a/src/features/foundation/document-template/queries.ts b/src/features/foundation/document-template/queries.ts index b288c33..4330233 100644 --- a/src/features/foundation/document-template/queries.ts +++ b/src/features/foundation/document-template/queries.ts @@ -1,5 +1,10 @@ import { queryOptions } from '@tanstack/react-query'; -import { getDocumentTemplateById, getDocumentTemplates, getDocumentTemplateVersions } from './service'; +import { + getDocumentTemplateById, + getDocumentTemplates, + getDocumentTemplateVersionManagement, + getDocumentTemplateVersions, +} from './service'; import type { DocumentTemplateFilters } from './types'; export const documentTemplateKeys = { @@ -10,7 +15,10 @@ export const documentTemplateKeys = { details: () => [...documentTemplateKeys.all, 'detail'] as const, detail: (id: string) => [...documentTemplateKeys.details(), id] as const, versionsRoot: () => [...documentTemplateKeys.all, 'versions'] as const, - versions: (id: string) => [...documentTemplateKeys.versionsRoot(), id] as const + versions: (id: string) => [...documentTemplateKeys.versionsRoot(), id] as const, + managementRoot: () => [...documentTemplateKeys.all, 'management'] as const, + management: (versionId: string) => + [...documentTemplateKeys.managementRoot(), versionId] as const, }; export const documentTemplatesQueryOptions = (filters: DocumentTemplateFilters = {}) => @@ -28,5 +36,11 @@ export const documentTemplateByIdOptions = (id: string) => export const documentTemplateVersionsQueryOptions = (id: string) => queryOptions({ queryKey: documentTemplateKeys.versions(id), - queryFn: () => getDocumentTemplateVersions(id) + queryFn: () => getDocumentTemplateVersions(id), + }); + +export const documentTemplateVersionManagementQueryOptions = (versionId: string) => + queryOptions({ + queryKey: documentTemplateKeys.management(versionId), + queryFn: () => getDocumentTemplateVersionManagement(versionId), }); diff --git a/src/features/foundation/document-template/server/management-service.ts b/src/features/foundation/document-template/server/management-service.ts new file mode 100644 index 0000000..594897d --- /dev/null +++ b/src/features/foundation/document-template/server/management-service.ts @@ -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 { + return schemaJson && typeof schemaJson === 'object' + ? { ...(schemaJson as Record) } + : {}; +} + +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 { + 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 { + 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 { + 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(); + 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, + 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 { + 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 = [ + '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 { + 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, + 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', + }; +} diff --git a/src/features/foundation/document-template/server/service.ts b/src/features/foundation/document-template/server/service.ts index 70b439e..16bff0e 100644 --- a/src/features/foundation/document-template/server/service.ts +++ b/src/features/foundation/document-template/server/service.ts @@ -15,6 +15,7 @@ import { import type { DocumentTemplateDetail, DocumentTemplateFilters, + DocumentTemplateLifecycleStatus, DocumentTemplateListItem, DocumentTemplateMappingMutationPayload, DocumentTemplateMappingRecord, @@ -29,6 +30,62 @@ import type { ResolvedDocumentTemplate } from '../types'; +const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5-template-management'; + +type DocumentTemplateManagementMetadata = { + lifecycleStatus?: DocumentTemplateLifecycleStatus; + runtimeVersion?: string | null; + templateVariant?: string | null; + brand?: string | null; + publishedAt?: string | null; + publishedBy?: string | null; + activatedAt?: string | null; + activatedBy?: string | null; + archivedAt?: string | null; + archivedBy?: string | null; + previousVersionId?: string | null; + nextVersionId?: string | null; + validatedAt?: string | null; +}; + +function getTemplateSchemaRecord(schemaJson: unknown): Record { + return schemaJson && typeof schemaJson === 'object' + ? { ...(schemaJson as Record) } + : {}; +} + +function getManagementMetadata(schemaJson: unknown): DocumentTemplateManagementMetadata { + const schema = getTemplateSchemaRecord(schemaJson); + const metadata = schema.__templateManagement; + return metadata && typeof metadata === 'object' + ? { ...(metadata as DocumentTemplateManagementMetadata) } + : {}; +} + +function withManagementMetadata( + schemaJson: unknown, + metadata: DocumentTemplateManagementMetadata +): unknown { + return { + ...getTemplateSchemaRecord(schemaJson), + __templateManagement: { + ...getManagementMetadata(schemaJson), + ...metadata + } + }; +} + +function inferLifecycleStatus(args: { + isActive: boolean; + metadata: DocumentTemplateManagementMetadata; +}): DocumentTemplateLifecycleStatus { + if (args.isActive) { + return 'active'; + } + + return args.metadata.lifecycleStatus ?? 'draft'; +} + function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): DocumentTemplateRecord { return { id: row.id, @@ -51,6 +108,8 @@ function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): Docum function mapVersionRecord( row: typeof crmDocumentTemplateVersions.$inferSelect ): DocumentTemplateVersionRecord { + const metadata = getManagementMetadata(row.schemaJson); + return { id: row.id, organizationId: row.organizationId, @@ -60,6 +119,21 @@ function mapVersionRecord( schemaJson: row.schemaJson, previewImageUrl: row.previewImageUrl, isActive: row.isActive, + lifecycleStatus: inferLifecycleStatus({ + isActive: row.isActive, + metadata + }), + runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION, + templateVariant: metadata.templateVariant ?? null, + brand: metadata.brand ?? null, + publishedAt: metadata.publishedAt ?? null, + publishedBy: metadata.publishedBy ?? null, + activatedAt: metadata.activatedAt ?? null, + activatedBy: metadata.activatedBy ?? null, + archivedAt: metadata.archivedAt ?? null, + archivedBy: metadata.archivedBy ?? null, + previousVersionId: metadata.previousVersionId ?? null, + nextVersionId: metadata.nextVersionId ?? null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null, diff --git a/src/features/foundation/document-template/service.ts b/src/features/foundation/document-template/service.ts index 0c9d0c4..3e6b7ef 100644 --- a/src/features/foundation/document-template/service.ts +++ b/src/features/foundation/document-template/service.ts @@ -5,20 +5,22 @@ import type { DocumentTemplateListResponse, DocumentTemplateMappingMutationPayload, DocumentTemplateMutationPayload, + DocumentTemplateVersionActionResponse, + DocumentTemplateVersionCompareResponse, DocumentTemplateVersionMutationPayload, + DocumentTemplateVersionPreviewResponse, + DocumentTemplateVersionValidateResponse, DocumentTemplateVersionsResponse, - MutationSuccessResponse + MutationSuccessResponse, } from './types'; const BASE_PATH = '/crm/settings/document-templates'; export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) { const searchParams = new URLSearchParams(); - if (filters.documentType) searchParams.set('documentType', filters.documentType); if (filters.fileType) searchParams.set('fileType', filters.fileType); if (filters.isActive) searchParams.set('isActive', filters.isActive); - const query = searchParams.toString(); return apiClient(`${BASE_PATH}${query ? `?${query}` : ''}`); } @@ -30,23 +32,23 @@ export async function getDocumentTemplateById(id: string) { export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) { return apiClient(BASE_PATH, { method: 'POST', - body: JSON.stringify(data) + body: JSON.stringify(data), }); } export async function updateDocumentTemplate( id: string, - data: Partial + data: Partial, ) { return apiClient(`${BASE_PATH}/${id}`, { method: 'PATCH', - body: JSON.stringify(data) + body: JSON.stringify(data), }); } export async function deleteDocumentTemplate(id: string) { return apiClient(`${BASE_PATH}/${id}`, { - method: 'DELETE' + method: 'DELETE', }); } @@ -56,53 +58,104 @@ export async function getDocumentTemplateVersions(id: string) { export async function createDocumentTemplateVersion( id: string, - data: DocumentTemplateVersionMutationPayload + data: DocumentTemplateVersionMutationPayload, ) { return apiClient(`${BASE_PATH}/${id}/versions`, { method: 'POST', - body: JSON.stringify(data) + body: JSON.stringify(data), }); } export async function updateDocumentTemplateVersion( id: string, - data: Partial + data: Partial, ) { return apiClient(`${BASE_PATH}/versions/${id}`, { method: 'PATCH', - body: JSON.stringify(data) + body: JSON.stringify(data), }); } export async function setDocumentTemplateVersionActive(id: string, isActive: boolean) { return apiClient(`${BASE_PATH}/versions/${id}`, { method: 'PATCH', - body: JSON.stringify({ isActive }) + body: JSON.stringify({ isActive }), }); } export async function createDocumentTemplateMapping( + templateId: string, versionId: string, - data: DocumentTemplateMappingMutationPayload + data: DocumentTemplateMappingMutationPayload, ) { + void templateId; return apiClient(`${BASE_PATH}/versions/${versionId}/mappings`, { method: 'POST', - body: JSON.stringify(data) + body: JSON.stringify(data), }); } export async function updateDocumentTemplateMapping( - id: string, - data: Partial + mappingId: string, + data: Partial, ) { - return apiClient(`${BASE_PATH}/mappings/${id}`, { + return apiClient(`${BASE_PATH}/mappings/${mappingId}`, { method: 'PATCH', - body: JSON.stringify(data) + body: JSON.stringify(data), }); } -export async function deleteDocumentTemplateMapping(id: string) { - return apiClient(`${BASE_PATH}/mappings/${id}`, { - method: 'DELETE' +export async function deleteDocumentTemplateMapping(mappingId: string) { + return apiClient(`${BASE_PATH}/mappings/${mappingId}`, { + method: 'DELETE', + }); +} + +export async function getDocumentTemplateVersionManagement(id: string) { + return apiClient(`${BASE_PATH}/versions/${id}/management`); +} + +export async function validateDocumentTemplateVersion(id: string) { + return apiClient(`${BASE_PATH}/versions/${id}/validate`, { + method: 'POST', + }); +} + +export async function publishDocumentTemplateVersion(id: string) { + return apiClient(`${BASE_PATH}/versions/${id}/publish`, { + method: 'POST', + }); +} + +export async function activateDocumentTemplateVersion(id: string) { + return apiClient(`${BASE_PATH}/versions/${id}/activate`, { + method: 'POST', + }); +} + +export async function rollbackDocumentTemplateVersion(id: string) { + return apiClient(`${BASE_PATH}/versions/${id}/rollback`, { + method: 'POST', + }); +} + +export async function archiveDocumentTemplateVersion(id: string) { + return apiClient(`${BASE_PATH}/versions/${id}/archive`, { + method: 'POST', + }); +} + +export async function previewDocumentTemplateVersion(id: string) { + return apiClient(`${BASE_PATH}/versions/${id}/preview`); +} + +export async function compareDocumentTemplateVersions( + templateId: string, + leftVersionId: string, + rightVersionId: string, +) { + return apiClient(`${BASE_PATH}/${templateId}/compare`, { + method: 'POST', + body: JSON.stringify({ leftVersionId, rightVersionId }), }); } diff --git a/src/features/foundation/document-template/types.ts b/src/features/foundation/document-template/types.ts index 715ce9c..1c32814 100644 --- a/src/features/foundation/document-template/types.ts +++ b/src/features/foundation/document-template/types.ts @@ -1,3 +1,10 @@ +export type DocumentTemplateLifecycleStatus = + | 'draft' + | 'validated' + | 'published' + | 'active' + | 'archived'; + export interface DocumentTemplateRecord { id: string; organizationId: string; @@ -15,6 +22,27 @@ export interface DocumentTemplateRecord { updatedBy: string; } +export interface DocumentTemplateVersionValidationSummary { + status: 'PASS' | 'WARNING' | 'FAIL'; + validatedAt: string | null; + requiredFieldsMissing: string[]; + duplicateFields: string[]; + unmappedPlaceholders: string[]; + runtimeIssues: Array<{ + code: string; + severity: 'warning' | 'error'; + message: string; + }>; +} + +export interface DocumentTemplateVersionAuditSummary { + status: 'PASS' | 'WARNING' | 'FAIL'; + auditedAt: string | null; + runtimeVersion: string | null; + visualRegressionStatus: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN'; + visualRegressionAt: string | null; +} + export interface DocumentTemplateVersionRecord { id: string; organizationId: string; @@ -24,6 +52,18 @@ export interface DocumentTemplateVersionRecord { schemaJson: unknown; previewImageUrl: string | null; isActive: boolean; + lifecycleStatus?: DocumentTemplateLifecycleStatus; + runtimeVersion?: string | null; + templateVariant?: string | null; + brand?: string | null; + publishedAt?: string | null; + publishedBy?: string | null; + activatedAt?: string | null; + activatedBy?: string | null; + archivedAt?: string | null; + archivedBy?: string | null; + previousVersionId?: string | null; + nextVersionId?: string | null; createdAt: string; updatedAt: string; deletedAt: string | null; @@ -80,16 +120,21 @@ export interface DocumentTemplateMappingMutationPayload { columns?: DocumentTemplateMappingColumnMutationPayload[]; } -export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord { +export interface DocumentTemplateMappingWithColumns + extends DocumentTemplateMappingRecord { columns: DocumentTemplateTableColumnRecord[]; } -export interface DocumentTemplateVersionDetail extends DocumentTemplateVersionRecord { +export interface DocumentTemplateVersionDetail + extends DocumentTemplateVersionRecord { mappings: DocumentTemplateMappingWithColumns[]; + validationSummary?: DocumentTemplateVersionValidationSummary | null; + auditSummary?: DocumentTemplateVersionAuditSummary | null; } export interface DocumentTemplateDetail extends DocumentTemplateRecord { versions: DocumentTemplateVersionDetail[]; + fileType: string; mappingCount: number; } @@ -102,28 +147,7 @@ export interface DocumentTemplateListItem extends DocumentTemplateRecord { export interface DocumentTemplateFilters { documentType?: string; fileType?: string; - isActive?: string; -} - -export interface DocumentTemplateListResponse { - success: boolean; - time: string; - message: string; - items: DocumentTemplateListItem[]; -} - -export interface DocumentTemplateDetailResponse { - success: boolean; - time: string; - message: string; - template: DocumentTemplateDetail; -} - -export interface DocumentTemplateVersionsResponse { - success: boolean; - time: string; - message: string; - items: DocumentTemplateVersionDetail[]; + isActive?: 'true' | 'false'; } export interface DocumentTemplateMutationPayload { @@ -142,11 +166,15 @@ export interface DocumentTemplateVersionMutationPayload { schemaJson: unknown; previewImageUrl?: string | null; isActive?: boolean; + runtimeVersion?: string | null; + templateVariant?: string | null; + brand?: string | null; + cloneFromVersionId?: string | null; } export interface ResolveTemplateParams { documentType: string; - productType?: string | null; + productType: string; fileType: string; } @@ -159,3 +187,62 @@ export interface MutationSuccessResponse { success: boolean; message: string; } + +export interface DocumentTemplateListResponse extends MutationSuccessResponse { + time: string; + items: DocumentTemplateListItem[]; +} + +export interface DocumentTemplateDetailResponse extends MutationSuccessResponse { + time: string; + template: DocumentTemplateDetail; +} + +export interface DocumentTemplateVersionsResponse extends MutationSuccessResponse { + time: string; + items: DocumentTemplateVersionDetail[]; +} + +export interface DocumentTemplateVersionActionResponse + extends MutationSuccessResponse { + version: DocumentTemplateVersionDetail; +} + +export interface DocumentTemplateVersionValidateResponse + extends MutationSuccessResponse { + validation: DocumentTemplateVersionValidationSummary; +} + +export interface DocumentTemplateVersionCompareResponse + extends MutationSuccessResponse { + comparison: { + leftVersionId: string; + rightVersionId: string; + metadataChanges: Array<{ + field: string; + left: string | number | boolean | null; + right: string | number | boolean | null; + }>; + placeholderOnlyLeft: string[]; + placeholderOnlyRight: string[]; + mappingDifferences: Array<{ + placeholderKey: string; + difference: string; + }>; + runtimeCompatibility: { + leftStatus: 'PASS' | 'WARNING' | 'FAIL'; + rightStatus: 'PASS' | 'WARNING' | 'FAIL'; + }; + }; +} + +export interface DocumentTemplateVersionPreviewResponse + extends MutationSuccessResponse { + preview: { + fileName: string; + contentType: 'application/pdf'; + base64: string; + pageCount: number; + source: 'audit-fixture'; + }; +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index 7f4ef13..d8f21d5 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -108,6 +108,10 @@ export const PERMISSIONS = { crmDocumentTemplateCreate: 'crm.document_template.create', crmDocumentTemplateUpdate: 'crm.document_template.update', crmDocumentTemplateDelete: 'crm.document_template.delete', + crmDocumentTemplatePublish: 'crm.document_template.publish', + crmDocumentTemplateActivate: 'crm.document_template.activate', + crmDocumentTemplateRollback: 'crm.document_template.rollback', + crmDocumentTemplateArchive: 'crm.document_template.archive', crmDocumentSequenceRead: 'crm.document_sequence.read', crmDocumentSequenceCreate: 'crm.document_sequence.create', crmDocumentSequenceUpdate: 'crm.document_sequence.update', @@ -653,6 +657,10 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [ { key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' }, { key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' }, { key: PERMISSIONS.crmDocumentTemplateDelete, label: 'Delete document templates' }, + { key: PERMISSIONS.crmDocumentTemplatePublish, label: 'Publish document templates' }, + { key: PERMISSIONS.crmDocumentTemplateActivate, label: 'Activate document templates' }, + { key: PERMISSIONS.crmDocumentTemplateRollback, label: 'Rollback document templates' }, + { key: PERMISSIONS.crmDocumentTemplateArchive, label: 'Archive document templates' }, { key: PERMISSIONS.crmDocumentSequenceRead, label: 'Read document sequences' }, { key: PERMISSIONS.crmDocumentSequenceCreate, label: 'Create document sequences' }, { key: PERMISSIONS.crmDocumentSequenceUpdate, label: 'Update document sequences' },