From e4573ac0c919978cacbbb226b094b25758094599 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Tue, 30 Jun 2026 09:21:34 +0700 Subject: [PATCH] task-p.5.1 --- .../placeholder-integrity-report.json | 2 +- .../pdf-audit/runtime-payload-report.json | 2 +- .../pdf-audit/signature-audit-report.json | 4 +- artifacts/pdf-audit/summary.json | 2 +- artifacts/pdf-audit/summary.md | 2 +- docs/implementation/pdf-audit-report.json | 6 +- docs/implementation/pdf-audit-report.md | 4 +- .../task-p.5.1-implementation.md | 181 + .../task-p.5.1-verification-report.md | 124 + drizzle/0006_lazy_shiver_man.sql | 11 + drizzle/meta/0006_snapshot.json | 5163 +++++++++++++++++ drizzle/meta/_journal.json | 7 + next-env.d.ts | 2 +- package.json | 12 +- plans/task-p.5.1.md | 422 ++ scripts/pdf-audit-utils.ts | 1638 +----- .../versions/[id]/duplicate/route.ts | 51 + src/db/schema.ts | 1 + .../template-version-management-panel.tsx | 288 +- .../foundation/document-template/mutations.ts | 20 + .../server/management-service.ts | 428 +- .../document-template/server/metadata.test.ts | 72 + .../document-template/server/metadata.ts | 86 + .../document-template/server/service.test.ts | 16 + .../document-template/server/service.ts | 201 +- .../foundation/document-template/service.ts | 6 + .../foundation/document-template/types.ts | 41 + .../foundation/pdf-audit/server/shared.ts | 1649 ++++++ 28 files changed, 8566 insertions(+), 1875 deletions(-) create mode 100644 docs/implementation/task-p.5.1-implementation.md create mode 100644 docs/implementation/task-p.5.1-verification-report.md create mode 100644 drizzle/0006_lazy_shiver_man.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 plans/task-p.5.1.md create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/duplicate/route.ts create mode 100644 src/features/foundation/document-template/server/metadata.test.ts create mode 100644 src/features/foundation/document-template/server/metadata.ts create mode 100644 src/features/foundation/document-template/server/service.test.ts create mode 100644 src/features/foundation/pdf-audit/server/shared.ts diff --git a/artifacts/pdf-audit/placeholder-integrity-report.json b/artifacts/pdf-audit/placeholder-integrity-report.json index a029cd0..4799981 100644 --- a/artifacts/pdf-audit/placeholder-integrity-report.json +++ b/artifacts/pdf-audit/placeholder-integrity-report.json @@ -17,7 +17,7 @@ { "templateName": "ALLA Quotation Standard", "organizationId": "d5c46139-9af4-421b-9395-d83620c62f9b", - "version": "1.0", + "version": "2.1", "duplicateFieldNames": [], "typoFields": [], "unmappedPlaceholders": [], diff --git a/artifacts/pdf-audit/runtime-payload-report.json b/artifacts/pdf-audit/runtime-payload-report.json index 24d083c..c2a8be7 100644 --- a/artifacts/pdf-audit/runtime-payload-report.json +++ b/artifacts/pdf-audit/runtime-payload-report.json @@ -4,7 +4,7 @@ "staticLabelsStatus": "PASS", "quotationCode": "QT2606-1122", "templateName": "ALLA Quotation Standard", - "templateVersion": "1.0", + "templateVersion": "2.1", "findingCount": 168, "findings": [ { diff --git a/artifacts/pdf-audit/signature-audit-report.json b/artifacts/pdf-audit/signature-audit-report.json index ad55acb..2d916ae 100644 --- a/artifacts/pdf-audit/signature-audit-report.json +++ b/artifacts/pdf-audit/signature-audit-report.json @@ -3,8 +3,8 @@ "overallStatus": "PASS", "quotationCode": "QT2606-1122", "approvedSnapshotAvailable": true, - "approvedTemplateVersionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89", - "computedTemplateVersionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89", + "approvedTemplateVersionId": "86f99414-4656-44a9-9937-8457794dc710", + "computedTemplateVersionId": "86f99414-4656-44a9-9937-8457794dc710", "inconsistentTemplateKeys": [], "computedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269", "storedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269", diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json index a067501..733c825 100644 --- a/artifacts/pdf-audit/summary.json +++ b/artifacts/pdf-audit/summary.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-29T08:14:00.088Z", + "generatedAt": "2026-06-30T01:35:49.157Z", "overallStatus": "PASS", "quotationCode": "QT2606-1122", "artifacts": { diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md index 7ebb41b..14e987f 100644 --- a/artifacts/pdf-audit/summary.md +++ b/artifacts/pdf-audit/summary.md @@ -1,6 +1,6 @@ # PDF Integrity Summary -- Generated At: 2026-06-29T08:14:00.088Z +- Generated At: 2026-06-30T01:35:49.157Z - Overall Status: PASS - Quotation Code: QT2606-1122 diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json index 87e0cf1..181c1dd 100644 --- a/docs/implementation/pdf-audit-report.json +++ b/docs/implementation/pdf-audit-report.json @@ -1,12 +1,12 @@ { - "generatedAt": "2026-06-29T08:13:58.800Z", + "generatedAt": "2026-06-30T01:35:48.376Z", "overallStatus": "PASS", "quotationCode": "QT2606-1122", "expectedFixtureCode": "QT-H5-AUDIT", "template": { "templateName": "ALLA Quotation Standard", - "version": "1.0", - "versionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89", + "version": "2.1", + "versionId": "86f99414-4656-44a9-9937-8457794dc710", "fieldCount": 44, "previousVersion": "2.0", "deletedFields": [ diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md index cca4798..7e793a3 100644 --- a/docs/implementation/pdf-audit-report.md +++ b/docs/implementation/pdf-audit-report.md @@ -1,9 +1,9 @@ # PDF Audit Report -- Generated At: 2026-06-29T08:13:58.800Z +- Generated At: 2026-06-30T01:35:48.376Z - Overall Status: PASS - Quotation Code: QT2606-1122 -- Template: ALLA Quotation Standard v1.0 +- Template: ALLA Quotation Standard v2.1 - Previous Version: 2.0 ## Metrics diff --git a/docs/implementation/task-p.5.1-implementation.md b/docs/implementation/task-p.5.1-implementation.md new file mode 100644 index 0000000..3307317 --- /dev/null +++ b/docs/implementation/task-p.5.1-implementation.md @@ -0,0 +1,181 @@ +# Task P.5.1 Implementation Report + +## Scope + +This report documents implementation work completed for +[task-p.5.1.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.5.1.md). + +Focus of this round: + +- production hardening for document template management +- metadata separation path from `schemaJson` +- duplicate version workflow +- stronger template health validation +- improved compare UX +- regression-safe migration support +- test coverage for new hardening helpers + +--- + +## Review Inputs + +Reviewed before implementation: + +- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md) +- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md) +- [docs/standards/architecture-rules.md](/C:/Users\\mtpphtaps\\Documents\\gitea\\alla-allaos-fullstack/docs/standards/architecture-rules.md) +- [docs/standards/task-catalog.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-catalog.md) +- [docs/implementation/task-p.5-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.5-implementation.md) +- [docs/implementation/task-p.5-verification-report.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.5-verification-report.md) + +--- + +## Implementation Summary + +### 1. Metadata separation foundation + +Added dedicated `metadata_json` support for document template versions: + +- [src/db/schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts) +- [drizzle/0006_lazy_shiver_man.sql](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0006_lazy_shiver_man.sql) + +Migration behavior: + +- add `metadata_json` +- backfill legacy `__templateManagement` from `schema_json` into `metadata_json` +- scrub embedded metadata from `schema_json` + +Compatibility helper added in: + +- [metadata.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/metadata.ts) + +This helper now: + +- reads dedicated metadata first +- falls back to legacy metadata inside `schemaJson` +- sanitizes exported schema so PDFMe JSON stays clean + +### 2. Document template server hardening + +Updated template server foundation in: + +- [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts) +- [management-service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/management-service.ts) + +Key changes: + +- sanitize `schemaJson` on create and update +- store lifecycle metadata in `metadataJson` +- preserve legacy metadata compatibility on read +- duplicate version support with cloned mappings and table columns +- stronger validation output with structured health issues +- compare output expanded with: + - field/schema changes + - mapping difference payload + - validation state for both versions + - basic JSON diff summary + +### 3. Duplicate-as-draft workflow + +Added explicit duplicate action: + +- [src/app/api/crm/settings/document-templates/versions/[id]/duplicate/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/duplicate/route.ts) + +Client and mutation support updated in: + +- [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/service.ts) +- [mutations.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/mutations.ts) + +Behavior implemented: + +- duplicate any source version into a new draft +- copy schema JSON +- copy mappings +- copy mapping columns +- clear active lifecycle state +- generate a unique draft/copy version label + +### 4. Compare UX improvement + +Rebuilt comparison dialog in: + +- [template-version-management-panel.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-version-management-panel.tsx) + +Compare UI now shows tabs for: + +- metadata +- placeholders +- mappings +- schema +- health + +This replaces the earlier toast-only compare result. + +### 5. Validation and health output + +Extended validation contract in: + +- [types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/types.ts) + +New validation output includes: + +- `healthStatus` +- structured `issues` +- `orphanMappings` +- `unsupportedSchemaTypes` +- `sectionMarkerIssues` +- suggested next step + +### 6. Audit utility relocation + +Moved reusable audit helpers into runtime code space: + +- [src/features/foundation/pdf-audit/server/shared.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-audit/server/shared.ts) + +Script shim now re-exports from: + +- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts) + +This removes direct runtime imports from `scripts/`. + +### 7. Automated tests added + +Added focused tests for hardening helpers: + +- [metadata.test.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/metadata.test.ts) +- [service.test.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.test.ts) + +Covered: + +- legacy metadata extraction +- schema sanitization +- metadata merge precedence +- duplicate version label generation + +--- + +## Generated Verification Artifacts + +Verification commands updated existing generated outputs: + +- [docs/implementation/pdf-audit-report.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/pdf-audit-report.json) +- [docs/implementation/pdf-audit-report.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/pdf-audit-report.md) +- [artifacts/pdf-audit/summary.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/artifacts/pdf-audit/summary.json) +- [artifacts/pdf-audit/summary.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/artifacts/pdf-audit/summary.md) + +These were not hand-edited as part of feature logic. They are generated by verification runs. + +--- + +## Known Gaps After This Round + +- Turbopack warning still persists during `npm run build`, even though runtime no longer imports from `scripts/` directly. +- Full route-level management tests were not added yet. +- Minimal browser/E2E verification was not added yet. +- `audit:pdf` still reports an existing active template schema drift for ALLA product template data in the current database state. + +--- + +## Outcome + +P.5.1 significantly improved the production-readiness of the document template management foundation, especially around metadata handling, draft duplication, health reporting, and compare usability, while keeping the PDF runtime path unchanged. diff --git a/docs/implementation/task-p.5.1-verification-report.md b/docs/implementation/task-p.5.1-verification-report.md new file mode 100644 index 0000000..61cb000 --- /dev/null +++ b/docs/implementation/task-p.5.1-verification-report.md @@ -0,0 +1,124 @@ +# Task P.5.1 Verification Report + +## Scope + +This report verifies implementation work for +[task-p.5.1.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.5.1.md). + +Verification focus: + +- metadata separation compile safety +- duplicate workflow compile safety +- compare UI compile safety +- helper test execution +- migration execution +- build and audit regression checks + +--- + +## Commands Run + +```bash +npm exec tsc --noEmit +npm exec tsx --test src/features/foundation/document-template/server/metadata.test.ts +npm exec tsx --test src/features/foundation/document-template/server/service.test.ts +npm run db:generate +npm run db:migrate +npm run build +npm run audit:pdf +``` + +--- + +## Results + +### TypeScript + +- `npm exec tsc --noEmit` = PASS + +### Added tests + +- `metadata.test.ts` = PASS +- `service.test.ts` = PASS + +### Migration flow + +- `npm run db:generate` = PASS +- `npm run db:migrate` = PASS + +### Build + +- `npm run build` = PASS + +Residual build status: + +- build warning still present from Turbopack trace on + [shared.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-audit/server/shared.ts) + +### PDF audit regression + +- `npm run audit:pdf` = command PASS +- `audit:pdf` integrity summary = FAIL + +Observed blocking audit issue: + +- `ACTIVE_VERSION_SCHEMA_DRIFT` +- current report points to ALLA active product template version not matching its source JSON + +Relevant generated artifact: + +- [artifacts/pdf-audit/template-version-report.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/artifacts/pdf-audit/template-version-report.json) + +--- + +## What Was Verified Successfully + +- document template metadata can now be modeled through dedicated `metadata_json` +- legacy embedded metadata remains readable through compatibility helpers +- duplicate version label generation works +- duplicate route was compiled into the app route tree +- management panel compiles with: + - duplicate action + - richer compare dialog + - stronger validation display +- migration file generated and applied successfully + +--- + +## What Remains Unverified or Incomplete + +- dedicated route-handler tests for validate/publish/activate/rollback/archive/duplicate +- minimal browser/E2E checks for `/dashboard/crm/settings/templates` +- full Turbopack warning cleanup +- clean `audit:pdf` integrity summary for current seeded DB state + +--- + +## Acceptance Status + +Acceptance criteria status for this round: + +- `schemaJson` clean-path implementation added = PASS +- compatibility handling for legacy metadata added = PASS +- duplicate as draft workflow added = PASS +- compare UI made substantially more usable = PASS +- stronger template health structure added = PASS +- helper-level automated tests added = PASS +- build remains PASS = PASS +- runtime code no longer imports `scripts/` directly = PASS +- Turbopack warning resolved = FAIL +- runtime audit remains fully green = FAIL +- route tests complete = FAIL +- minimal E2E workflow complete = FAIL + +--- + +## Verification Conclusion + +P.5.1 made meaningful hardening progress and is build-safe, migration-safe, and test-improved. + +However, this round does not fully satisfy every acceptance target yet because: + +- the Turbopack warning still remains +- route/E2E coverage is still incomplete +- current database verification still shows active template schema drift in `audit:pdf` diff --git a/drizzle/0006_lazy_shiver_man.sql b/drizzle/0006_lazy_shiver_man.sql new file mode 100644 index 0000000..b5dddb6 --- /dev/null +++ b/drizzle/0006_lazy_shiver_man.sql @@ -0,0 +1,11 @@ +ALTER TABLE "crm_document_template_versions" ADD COLUMN "metadata_json" jsonb; +--> statement-breakpoint +UPDATE "crm_document_template_versions" +SET + "metadata_json" = COALESCE("metadata_json", "schema_json" -> '__templateManagement'), + "schema_json" = CASE + WHEN "schema_json" ? '__templateManagement' + THEN "schema_json" - '__templateManagement' + ELSE "schema_json" + END +WHERE "schema_json" ? '__templateManagement'; diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..210ba92 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,5163 @@ +{ + "id": "61cab7a1-31a8-47e2-b563-e6c9dc313f32", + "prevId": "6b9c3902-feb2-4170-a430-092e68140a0f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_notification_deliveries": { + "name": "app_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_id": { + "name": "notification_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_events": { + "name": "app_notification_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notification_events_org_dedupe_idx": { + "name": "app_notification_events_org_dedupe_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notification_templates": { + "name": "app_notification_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_template": { + "name": "title_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_template": { + "name": "link_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "app_notification_templates_org_event_channel_idx": { + "name": "app_notification_templates_org_event_channel_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_notifications": { + "name": "app_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unread'" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_notifications_recipient_event_idx": { + "name": "app_notifications_recipient_event_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_actions": { + "name": "crm_approval_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_request_id": { + "name": "approval_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acted_by": { + "name": "acted_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_escalation_policies": { + "name": "crm_approval_escalation_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "trigger_after_hours": { + "name": "trigger_after_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_value": { + "name": "target_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_matrices": { + "name": "crm_approval_matrices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "min_amount": { + "name": "min_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_amount": { + "name": "max_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_reminder_policies": { + "name": "crm_approval_reminder_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sla_hours": { + "name": "sla_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "reminder_offsets_hours": { + "name": "reminder_offsets_hours", + "type": "integer[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "calendar_mode": { + "name": "calendar_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'calendar_days'" + }, + "timeout_action": { + "name": "timeout_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_approval_reminder_policies_workflow_step_idx": { + "name": "crm_approval_reminder_policies_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_requests": { + "name": "crm_approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "current_step_started_at": { + "name": "current_step_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_steps": { + "name": "crm_approval_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_code": { + "name": "role_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_mode": { + "name": "approval_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sequential'" + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sla_hours": { + "name": "sla_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "calendar_mode": { + "name": "calendar_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'calendar_days'" + }, + "timeout_action": { + "name": "timeout_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_steps_workflow_step_idx": { + "name": "crm_approval_steps_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_workflows": { + "name": "crm_approval_workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_workflows_org_code_idx": { + "name": "crm_approval_workflows_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_contact_shares": { + "name": "crm_contact_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_to_user_id": { + "name": "shared_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_by_user_id": { + "name": "shared_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_contact_shares_org_contact_shared_user_idx": { + "name": "crm_contact_shares_org_contact_shared_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_contacts": { + "name": "crm_customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_owner_history": { + "name": "crm_customer_owner_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_owner_user_id": { + "name": "old_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_owner_user_id": { + "name": "new_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changed_at": { + "name": "changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customers": { + "name": "crm_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbr": { + "name": "abbr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_status": { + "name": "customer_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub_district": { + "name": "sub_district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fax": { + "name": "fax", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_group": { + "name": "customer_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_sub_group": { + "name": "customer_sub_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_at": { + "name": "owner_assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_by": { + "name": "owner_assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_customers_org_code_idx": { + "name": "crm_customers_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_artifacts": { + "name": "crm_document_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "generated_by": { + "name": "generated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voided_at": { + "name": "voided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "voided_by": { + "name": "voided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "void_reason": { + "name": "void_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_mappings": { + "name": "crm_document_template_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "placeholder_key": { + "name": "placeholder_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_type": { + "name": "data_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheet_name": { + "name": "sheet_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_value": { + "name": "default_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_mappings_version_placeholder_idx": { + "name": "crm_document_template_mappings_version_placeholder_idx", + "columns": [ + { + "expression": "template_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "placeholder_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_table_columns": { + "name": "crm_document_template_table_columns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mapping_id": { + "name": "mapping_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_name": { + "name": "column_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_field": { + "name": "source_field", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_letter": { + "name": "column_letter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_table_columns_mapping_name_idx": { + "name": "crm_document_template_table_columns_mapping_name_idx", + "columns": [ + { + "expression": "mapping_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "column_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_versions": { + "name": "crm_document_template_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_json": { + "name": "schema_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_image_url": { + "name": "preview_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_template_versions_template_version_idx": { + "name": "crm_document_template_versions_template_version_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_templates": { + "name": "crm_document_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_templates_org_doc_product_file_name_idx": { + "name": "crm_document_templates_org_doc_product_file_name_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_leads": { + "name": "crm_leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "awareness_id": { + "name": "awareness_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new_job'" + }, + "followup_status": { + "name": "followup_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "owner_marketing_user_id": { + "name": "owner_marketing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_sales_owner_id": { + "name": "assigned_sales_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_leads_org_code_idx": { + "name": "crm_leads_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunities": { + "name": "crm_opportunities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requirement": { + "name": "requirement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome_status": { + "name": "outcome_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_close_date": { + "name": "expected_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lead'" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_won_at": { + "name": "closed_won_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_lost_at": { + "name": "closed_lost_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by_user_id": { + "name": "closed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_number": { + "name": "po_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_date": { + "name": "po_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "po_amount": { + "name": "po_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "po_currency": { + "name": "po_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_detail": { + "name": "lost_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_competitor": { + "name": "lost_competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_remark": { + "name": "lost_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "no_quotation_reason": { + "name": "no_quotation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_to_user_id": { + "name": "assigned_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_opportunities_org_code_idx": { + "name": "crm_opportunities_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_attachments": { + "name": "crm_opportunity_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_customers": { + "name": "crm_opportunity_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunity_followups": { + "name": "crm_opportunity_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_attachments": { + "name": "crm_quotation_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_customers": { + "name": "crm_quotation_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_followups": { + "name": "crm_quotation_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_items": { + "name": "crm_quotation_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_number": { + "name": "item_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_price": { + "name": "total_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topic_items": { + "name": "crm_quotation_topic_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topics": { + "name": "crm_quotation_topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_type": { + "name": "topic_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotations": { + "name": "crm_quotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotation_date": { + "name": "quotation_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quotation_type": { + "name": "quotation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attention": { + "name": "attention", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_quotation_id": { + "name": "parent_quotation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_remark": { + "name": "revision_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exchange_rate": { + "name": "exchange_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "subtotal": { + "name": "subtotal", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tax_amount": { + "name": "tax_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_amount": { + "name": "total_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "salesman_id": { + "name": "salesman_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_sent": { + "name": "is_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_via": { + "name": "sent_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_artifact_id": { + "name": "approved_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_pdf_url": { + "name": "approved_pdf_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_snapshot": { + "name": "approved_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_template_version_id": { + "name": "approved_template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_quotations_org_code_idx": { + "name": "crm_quotations_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_report_definitions": { + "name": "crm_report_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "crm_report_definitions_org_code_idx": { + "name": "crm_report_definitions_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_role_profiles": { + "name": "crm_role_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "product_scope_mode": { + "name": "product_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "ownership_scope": { + "name": "ownership_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'own'" + }, + "approval_authority": { + "name": "approval_authority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_role_profiles_org_code_idx": { + "name": "crm_role_profiles_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_user_role_assignments": { + "name": "crm_user_role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_mode": { + "name": "product_type_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_user_role_assignments_org_user_role_idx": { + "name": "crm_user_role_assignments_org_user_role_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_number": { + "name": "current_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "padding_length": { + "name": "padding_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_sequences_org_doc_period_branch_idx": { + "name": "document_sequences_org_doc_period_branch_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "business_role": { + "name": "business_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sales_support'" + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ms_options": { + "name": "ms_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ms_options_org_category_code_idx": { + "name": "ms_options_org_category_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "products_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tr_audit_logs": { + "name": "tr_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_data": { + "name": "before_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_data": { + "name": "after_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index fb7afd2..b6c40ff 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1782464526434, "tag": "0005_furry_maelstrom", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1782749787685, + "tag": "0006_lazy_shiver_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 043b1ed..1ca7fb0 100644 --- a/package.json +++ b/package.json @@ -47,11 +47,13 @@ "audit:pdf:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts", "audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts", "audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts", -"audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts", -"audit:pdf:visual": "npx tsx scripts/audit-pdf-visual-regression.ts", -"audit:pdf:visual:baseline": "npx tsx scripts/audit-pdf-visual-regression.ts -- --write-baseline", -"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity", - "setup:db": "npm run db:setup" + "audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts", + "audit:pdf:visual": "npx tsx scripts/audit-pdf-visual-regression.ts", + "audit:pdf:visual:baseline": "npx tsx scripts/audit-pdf-visual-regression.ts -- --write-baseline", + "audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity", + "setup:db": "npm run db:setup", + "pdf:reseed": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts", + "pdf:activate:product": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate" }, "dependencies": { "@aws-sdk/client-s3": "^3.1069.0", diff --git a/plans/task-p.5.1.md b/plans/task-p.5.1.md new file mode 100644 index 0000000..fa8a7a8 --- /dev/null +++ b/plans/task-p.5.1.md @@ -0,0 +1,422 @@ +# Task P.5.1 - Document Template Management Production Hardening + +## Objective + +Harden the Document Template Management foundation from Task P.5 so it is ready for production use and future extension. + +This task focuses on technical debt, maintainability, validation, test coverage, and production-readiness. + +Do NOT change PDF runtime behavior. +Do NOT change Product Item Engine behavior. +Do NOT introduce Document Assembly or PDF Merge. +Do NOT introduce Render Configuration UI. + +--- + +# Background + +Task P.5 introduced the Document Template Management foundation. + +Implemented capabilities include: + +* lifecycle operations +* validate +* publish +* activate +* rollback +* archive +* preview +* compare +* audit summary +* visual regression summary +* management UI panel + +Known remaining risks: + +* lifecycle metadata currently lives inside `schemaJson` +* compare UI is minimal +* duplicate version shortcut is not explicit +* management route regression tests are missing +* browser/E2E verification is missing +* Turbopack build warning exists due to filesystem tracing from audit utilities + +--- + +# Scope + +Included: + +* metadata separation design and implementation +* migration-safe lifecycle metadata handling +* duplicate version workflow +* version comparison UX improvement +* template health validation +* management route tests +* minimal browser/E2E checks +* build warning cleanup +* management audit hardening + +Excluded: + +* PDF runtime refactor +* Product Item template changes +* Document Assembly +* SLA merge +* Render Policy UI +* Optional section configuration +* Document Library + +--- + +# Phase 1 - Metadata Separation + +## Problem + +Task P.5 stores lifecycle metadata inside `schemaJson`. + +This is acceptable for foundation delivery but not ideal long term. + +`schemaJson` should remain PDFMe template JSON only. + +## Goal + +Separate management metadata from PDFMe schema JSON. + +Preferred approach: + +```text +crm_document_template_versions + schema_json = pure PDFMe JSON + metadata_json = lifecycle/management metadata +``` + +If a database migration is approved, add `metadata_json`. + +If schema migration is not allowed in this task, implement a compatibility adapter that: + +* reads legacy metadata from `schemaJson` +* writes new metadata into a dedicated safe location only after schema support exists +* prevents metadata from leaking into exported PDFMe JSON + +## Required Metadata + +Support: + +* lifecycleStatus +* runtimeVersion +* templateVariant +* brand +* publishedBy +* publishedAt +* activatedBy +* activatedAt +* archivedBy +* archivedAt +* previousVersionId +* validationSummary +* auditSummary +* visualSummary + +## Acceptance + +* Exported template JSON contains only valid PDFMe JSON. +* Runtime continues reading valid `schemaJson`. +* Existing versions remain readable. +* No template version data loss. + +--- + +# Phase 2 - Duplicate Version Workflow + +Add an explicit duplicate action. + +## Behavior + +Duplicate should: + +* clone an existing version +* create a new Draft version +* copy schema JSON +* copy mappings +* reset lifecycle metadata +* clear active/published state +* assign a new version label + +Example: + +```text +2.0 -> 2.0-copy-1 +``` + +or + +```text +2.0 -> 2.1-draft +``` + +## UI + +Add button: + +```text +Duplicate as Draft +``` + +## Acceptance + +* User can duplicate any non-archived version. +* New version is Draft. +* Existing active version remains unchanged. +* Mappings are copied correctly. + +--- + +# Phase 3 - Version Compare UX + +Improve compare from backend-only result to user-readable UI. + +## Compare Tabs + +Show: + +1. Metadata diff +2. Placeholder diff +3. Mapping diff +4. Field/schema diff +5. JSON diff + +## Minimum UX + +* side-by-side version selector +* highlight added fields +* highlight removed fields +* highlight changed mappings +* show validation status of both versions + +## Acceptance + +* User can compare any two versions of the same template. +* UI explains differences without opening raw JSON manually. +* Compare does not mutate data. + +--- + +# Phase 4 - Template Health Validation + +Add a stronger validation layer before publish. + +## Validate + +* JSON parse validity +* PDFMe schema validity +* duplicate field names +* unknown plugins +* unsupported schema types +* missing required placeholders +* orphan mappings +* unmapped placeholders +* section marker validity +* missing active-compatible page roles +* items_table validity for product templates +* topic template validity +* signature field validity + +## Output + +Return structured health result: + +```ts +type TemplateHealthStatus = 'pass' | 'warning' | 'fail'; +``` + +With: + +* issues +* severity +* location +* suggested fix + +## Acceptance + +* Invalid templates cannot be published. +* Warnings are visible. +* Health result is shown in UI. + +--- + +# Phase 5 - Management Route Tests + +Add automated tests for management endpoints. + +## Test Routes + +* validate +* publish +* activate +* rollback +* archive +* preview +* compare +* duplicate + +## Test Cases + +* unauthorized request +* missing version +* invalid lifecycle transition +* publish invalid template +* activate unpublished version +* rollback from active version +* archive active version protection +* compare incompatible versions + +## Acceptance + +* Tests run in CI-friendly mode. +* Tests do not require manual DB cleanup. +* Existing `npm exec tsc --noEmit` remains PASS. + +--- + +# Phase 6 - Minimal E2E Verification + +Add minimal browser-level verification for: + +```text +/dashboard/crm/settings/templates +``` + +## Verify + +* page loads +* version list renders +* management panel renders +* validation action can be triggered +* preview action can be triggered +* compare dialog/page opens +* duplicate action creates draft +* archive confirmation appears + +## Acceptance + +* E2E covers the core management workflow. +* Tests can run against local seeded data. + +--- + +# Phase 7 - Build Warning Cleanup + +## Current Warning + +Build passes but Turbopack reports filesystem tracing warning from: + +```text +scripts/pdf-audit-utils.ts +``` + +## Goal + +Remove server build dependency on script-only utilities. + +## Recommended Fix + +Move shared audit logic into: + +```text +src/features/foundation/pdf-audit/server/ +``` + +Keep script wrappers in: + +```text +scripts/ +``` + +Script files should import shared logic, not the other way around. + +## Acceptance + +* `npm run build` passes without the filesystem tracing warning. +* Runtime code does not import from `scripts/`. +* Scripts may import from runtime-safe shared modules. + +--- + +# Phase 8 - Regression Verification + +Run: + +```bash +npm exec tsc --noEmit +npm run build +npm run audit:pdf +``` + +Also run any route tests and E2E tests added in this task. + +Verify: + +* legacy template still works +* product template still works +* PDF runtime remains unchanged +* visual regression baselines remain valid +* template management actions work + +--- + +# Deliverables + +* separated metadata handling +* duplicate version workflow +* compare UX improvement +* template health validation +* management route tests +* minimal E2E coverage +* build warning cleanup +* updated documentation + +--- + +# Acceptance Criteria + +* `schemaJson` remains clean PDFMe JSON. +* Lifecycle metadata no longer pollutes exported templates. +* Existing versions remain readable through compatibility handling. +* Duplicate as Draft works. +* Compare UI is usable. +* Template Health blocks invalid publish. +* Management route tests pass. +* Minimal E2E workflow passes. +* Turbopack warning is resolved or explicitly documented if unavoidable. +* Runtime audit remains PASS. +* Build remains PASS. + +--- + +# Out of Scope + +## P.5.2 + +* Document Library +* SLA PDF records +* Warranty PDF records +* Appendix records + +## P.6 + +* PDF Merge +* SLA Assembly +* Warranty Assembly +* Final document package generation + +## P.7 + +* Render Configuration +* Optional section UI +* User-selectable Product Table visibility + +--- + +# Final Success Condition + +Task P.5.1 is complete when Document Template Management is production-hardened, metadata is safely separated from PDFMe schema JSON, version operations are test-covered, comparison and validation are usable, and build/runtime regression checks remain fully green. diff --git a/scripts/pdf-audit-utils.ts b/scripts/pdf-audit-utils.ts index 7b16761..16a01e9 100644 --- a/scripts/pdf-audit-utils.ts +++ b/scripts/pdf-audit-utils.ts @@ -1,1637 +1 @@ -import fs from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { createHash } from 'node:crypto'; -import postgres from 'postgres'; -import { resolveQuotationStaticTemplateInputs } from '../src/features/crm/quotations/document/server/static-input-resolver.ts'; - -export const AUDIT_QUOTATION_CODE = 'QT-H5-AUDIT'; -export const AUDIT_ARTIFACTS_DIR = path.resolve(process.cwd(), 'artifacts', 'pdf-audit'); -export const REPORT_JSON_PATH = path.resolve( - process.cwd(), - 'docs/implementation/pdf-audit-report.json' -); -export const REPORT_MARKDOWN_PATH = path.resolve( - process.cwd(), - 'docs/implementation/pdf-audit-report.md' -); - -export type SqlClient = ReturnType; - -export interface TemplateFieldRecord { - pageIndex: number; - fieldIndex: number; - name: string; - type: string; - tokens: string[]; -} - -export interface ActiveTemplateVersionRecord { - templateId: string; - templateName: string; - organizationId: string; - documentType: string; - productType: string; - fileType: string; - versionId: string; - version: string; - filePath: string | null; - schemaJson: unknown; -} - -export interface MappingRecord { - id: string; - organizationId: string; - templateVersionId: string; - placeholderKey: string; - sourcePath: string; - dataType: 'scalar' | 'multiline' | 'table' | 'image'; - defaultValue: string | null; - formatMask: string | null; - sortOrder: number; -} - -export interface MappingColumnRecord { - mappingId: string; - columnName: string; - sourceField: string; - sortOrder: number; - formatMask: string | null; -} - -export type TemplateFieldClassification = - | 'static input field' - | 'dynamic topic template' - | 'readonly/static visual field' - | 'system field' - | 'unknown field'; - -export interface TemplateMappingSeed { - placeholderKey: string; - sourcePath: string; - dataType: 'scalar' | 'multiline' | 'table' | 'image'; - defaultValue?: string | null; - formatMask?: string | null; - sortOrder: number; - columns?: Array<{ - columnName: string; - sourceField: string; - columnLetter?: string; - sortOrder: number; - formatMask?: string | null; - }>; -} - -export interface AuditPayload { - organizationId: string; - quotationId: string; - quotationCode: string; - templateVersionId: string; - approvedTemplateVersionId: string | null; - templateName: string; - templateVersion: string; - templateSchema: unknown; - mappings: MappingRecord[]; - mappingColumns: MappingColumnRecord[]; - documentData: Record; - templateInput: Record; - topicInputs: Record; - quotationMetrics: { - topicCount: number; - itemCount: number; - partyCount: number; - }; - approvedSnapshot: Record | null; - approvedArtifactReference: string | null; -} - -const SYSTEM_FIELD_NAMES = new Set([ - '', - 'company1', - 'company', - 'company2', - 'company_addr1', - 'company_addr2', - 'company_tel', - 'company_email', - 'company_tax', - 'Please_do_not', - 'yours_faithfuly' -]); - -const DYNAMIC_TOPIC_FIELD_NAMES = new Set(['topic', 'data_topic']); -const LEGACY_DYNAMIC_SEED_TOKENS = new Set(['item_topic']); -const DYNAMIC_TOPIC_TOKEN_PREFIXES = ['topic_', 'item_topic_']; -const DESIGNER_LABEL_NAME_PATTERNS = [/_label$/i, /_lable$/i]; -const STATIC_VISUAL_FIELD_NAMES = new Set([ - 'colon', - 'colon copy', - 'colon_att', - 'colon_email', - 'colon_fax', - 'colon_project', - 'colon_site', - 'colon_tel', - 'dear_sirs', - 'line', - 'yours_faithfuly', - 'quotation_code_lable', - 'quotation_date_labe' -]); -export const PRICE_TABLE_LABEL = 'Price (Exclude VAT)'; -export const PDFME_STATIC_LABELS = { - tel: 'Tel', - email: 'Email', - att: 'Att', - project: 'Project', - location: 'Location' -} as const; -export const PDFME_STATIC_LABEL_FIELD_KEYS = [ - 'tel_label', - 'email_label', - 'att_label', - 'project_label', - 'site_label' -] as const; -export type TemplateSourceVariant = 'legacy' | 'product-v1'; -export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [ - { - placeholderKey: 'customer_name', - sourcePath: 'customer.name', - dataType: 'scalar', - sortOrder: 1 - }, - { - placeholderKey: 'customer_addr', - sourcePath: 'customer.address', - dataType: 'multiline', - sortOrder: 2 - }, - { - placeholderKey: 'customer_tel', - sourcePath: 'customer.phone', - dataType: 'scalar', - sortOrder: 3 - }, - { - placeholderKey: 'customer_email', - sourcePath: 'customer.email', - dataType: 'scalar', - sortOrder: 4 - }, - { - placeholderKey: 'customer_att', - sourcePath: 'quotation.attention', - dataType: 'scalar', - sortOrder: 5 - }, - { - placeholderKey: 'project_name', - sourcePath: 'quotation.projectName', - dataType: 'scalar', - sortOrder: 6 - }, - { - placeholderKey: 'site_location', - sourcePath: 'quotation.projectLocation', - dataType: 'multiline', - sortOrder: 7 - }, - { - placeholderKey: 'quotation_date_data', - sourcePath: 'pdfme.quotation_date', - dataType: 'scalar', - sortOrder: 8 - }, - { - placeholderKey: 'quotation_code_data', - sourcePath: 'quotation.code', - dataType: 'scalar', - sortOrder: 9 - }, - { - placeholderKey: 'quotation_price_data', - sourcePath: 'pdfme.quotation_price_data', - dataType: 'table', - defaultValue: JSON.stringify([[PRICE_TABLE_LABEL, '-', ' ', 'THB']]), - sortOrder: 10 - }, - { - placeholderKey: 'quotation_price', - sourcePath: 'pdfme.quotation_price', - dataType: 'scalar', - sortOrder: 11 - }, - { - placeholderKey: 'currency', - sourcePath: 'quotation.currency', - dataType: 'scalar', - sortOrder: 12 - }, - { - placeholderKey: 'exclusion_data', - sourcePath: 'pdfme.exclusion_data', - dataType: 'table', - sortOrder: 13 - }, - { - placeholderKey: 'tel_label', - sourcePath: 'pdfme.labels.tel', - dataType: 'scalar', - defaultValue: PDFME_STATIC_LABELS.tel, - sortOrder: 14 - }, - { - placeholderKey: 'email_label', - sourcePath: 'pdfme.labels.email', - dataType: 'scalar', - defaultValue: PDFME_STATIC_LABELS.email, - sortOrder: 15 - }, - { - placeholderKey: 'att_label', - sourcePath: 'pdfme.labels.att', - dataType: 'scalar', - defaultValue: PDFME_STATIC_LABELS.att, - sortOrder: 16 - }, - { - placeholderKey: 'project_label', - sourcePath: 'pdfme.labels.project', - dataType: 'scalar', - defaultValue: PDFME_STATIC_LABELS.project, - sortOrder: 17 - }, - { - placeholderKey: 'site_label', - sourcePath: 'pdfme.labels.location', - dataType: 'scalar', - defaultValue: PDFME_STATIC_LABELS.location, - sortOrder: 18 - }, - { - placeholderKey: 'app1', - sourcePath: 'signatures.preparedBy.name', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 19 - }, - { - placeholderKey: 'app1_position', - sourcePath: 'signatures.preparedBy.position', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 20 - }, - { - placeholderKey: 'app2', - sourcePath: 'signatures.approvedBy.name', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 21 - }, - { - placeholderKey: 'app2_position', - sourcePath: 'signatures.approvedBy.position', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 22 - }, - { - placeholderKey: 'app3', - sourcePath: 'signatures.authorizedBy.name', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 23 - }, - { - placeholderKey: 'app3_position', - sourcePath: 'signatures.authorizedBy.position', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 24 - }, - { - placeholderKey: 'items_table', - sourcePath: 'items', - dataType: 'table', - sortOrder: 25, - columns: [ - { columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 }, - { columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 }, - { columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 }, - { columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 }, - { - columnName: 'Unit Price', - sourceField: 'unitPrice', - columnLetter: 'E', - sortOrder: 5, - formatMask: 'currency' - }, - { - columnName: 'Discount', - sourceField: 'discount', - columnLetter: 'F', - sortOrder: 6, - formatMask: 'currency' - }, - { - columnName: 'Total', - sourceField: 'totalPrice', - columnLetter: 'G', - sortOrder: 7, - formatMask: 'currency' - } - ] - } -] as const; -const SIGNATURE_ROLE_FALLBACK_LABELS: Record = { - sales: 'Sales Engineer', - sales_support: 'Sales Support', - sales_manager: 'Sales Manager', - department_manager: 'Department Manager', - top_manager: 'General Manager' -}; - -export function loadEnvFile(filePath: string) { - if (!fs.existsSync(filePath)) { - return; - } - - const content = fs.readFileSync(filePath, 'utf8'); - - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim(); - - if (!line || line.startsWith('#')) { - continue; - } - - const separatorIndex = line.indexOf('='); - - if (separatorIndex === -1) { - continue; - } - - const key = line.slice(0, separatorIndex).trim(); - let value = line.slice(separatorIndex + 1).trim(); - - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - if (!(key in process.env)) { - process.env[key] = value; - } - } -} - -export function loadLocalEnv() { - loadEnvFile(path.resolve(process.cwd(), '.env.local')); - loadEnvFile(path.resolve(process.cwd(), '.env')); -} - -export function requireEnv(name: string) { - const value = process.env[name]?.trim(); - - if (!value) { - throw new Error(`Missing required environment variable: ${name}`); - } - - return value; -} - -export function createSqlClient() { - loadLocalEnv(); - return postgres(requireEnv('DATABASE_URL'), { - prepare: false - }); -} - -export async function ensureAuditArtifactsDir() { - await mkdir(AUDIT_ARTIFACTS_DIR, { recursive: true }); -} - -export async function writeAuditArtifact(fileName: string, payload: unknown) { - await ensureAuditArtifactsDir(); - const targetPath = path.resolve(AUDIT_ARTIFACTS_DIR, fileName); - await writeFile(targetPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); - return targetPath; -} - -export function computePayloadHash(value: unknown) { - return createHash('sha256').update(JSON.stringify(value)).digest('hex'); -} - -export function extractTokensFromValue(value: unknown) { - const tokens = new Set(); - - if (typeof value !== 'string') { - return tokens; - } - - for (const match of value.matchAll(/\{([^}]+)\}/g)) { - tokens.add(match[1]); - } - - try { - const parsed = JSON.parse(value) as unknown; - - if (Array.isArray(parsed)) { - for (const row of parsed) { - if (!Array.isArray(row)) { - continue; - } - - for (const cell of row) { - if (typeof cell !== 'string') { - continue; - } - - for (const match of cell.matchAll(/\{([^}]+)\}/g)) { - tokens.add(match[1]); - } - } - } - } - } catch { - return tokens; - } - - return tokens; -} - -export function extractSinglePlaceholder(value: unknown) { - if (typeof value !== 'string') { - return null; - } - - const matches = [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]); - const uniqueMatches = [...new Set(matches)]; - - return uniqueMatches.length === 1 ? uniqueMatches[0] : null; -} - -function normalizeSchemaJson(schemaJson: unknown) { - if (typeof schemaJson === 'string') { - try { - return JSON.parse(schemaJson) as unknown; - } catch { - return null; - } - } - - return schemaJson; -} - -export function extractTemplateFields(schemaJson: unknown) { - const fields: TemplateFieldRecord[] = []; - const normalizedSchemaJson = normalizeSchemaJson(schemaJson); - - if ( - !normalizedSchemaJson || - typeof normalizedSchemaJson !== 'object' || - !('schemas' in normalizedSchemaJson) - ) { - return fields; - } - - const pages = (normalizedSchemaJson as { schemas?: unknown }).schemas; - - if (!Array.isArray(pages)) { - return fields; - } - - for (const [pageIndex, page] of pages.entries()) { - if (!Array.isArray(page)) { - continue; - } - - for (const [fieldIndex, field] of page.entries()) { - if (!field || typeof field !== 'object') { - continue; - } - - const typedField = field as { - name?: unknown; - type?: unknown; - content?: unknown; - head?: unknown; - }; - const tokens = new Set(); - - for (const token of extractTokensFromValue(typedField.content)) { - tokens.add(token); - } - - if (Array.isArray(typedField.head)) { - for (const value of typedField.head) { - for (const token of extractTokensFromValue(value)) { - tokens.add(token); - } - } - } - - fields.push({ - pageIndex, - fieldIndex, - name: typeof typedField.name === 'string' ? typedField.name : '', - type: typeof typedField.type === 'string' ? typedField.type : 'unknown', - tokens: [...tokens] - }); - } - } - - return fields; -} - -export function getDuplicateFieldNames(fields: TemplateFieldRecord[]) { - const fieldNameCounts = new Map(); - - for (const field of fields) { - if (!field.name) { - continue; - } - - fieldNameCounts.set(field.name, (fieldNameCounts.get(field.name) ?? 0) + 1); - } - - return [...fieldNameCounts.entries()] - .filter(([, count]) => count > 1) - .map(([name]) => name) - .sort(); -} - -export function getUnknownFieldNames( - fields: TemplateFieldRecord[], - mappedKeys: Set = new Set() -) { - return fields - .filter( - (field) => - field.name && classifyTemplateField(field, mappedKeys) === 'unknown field' - ) - .map((field) => field.name) - .sort(); -} - -export function isDesignerLabelField(field: TemplateFieldRecord) { - return ( - field.type === 'text' && - field.tokens.length === 0 && - DESIGNER_LABEL_NAME_PATTERNS.some((pattern) => pattern.test(field.name)) - ); -} - -export function classifyTemplateField( - field: TemplateFieldRecord, - mappedKeys: Set = new Set() -): TemplateFieldClassification { - if (!field.name) { - return 'system field'; - } - - if (SYSTEM_FIELD_NAMES.has(field.name)) { - return 'system field'; - } - - if (DYNAMIC_TOPIC_FIELD_NAMES.has(field.name)) { - return 'dynamic topic template'; - } - - if (mappedKeys.has(field.name)) { - return 'static input field'; - } - - if (isDesignerLabelField(field) || STATIC_VISUAL_FIELD_NAMES.has(field.name)) { - return 'readonly/static visual field'; - } - - if ( - field.tokens.length === 0 && - ['text', 'line', 'rectangle', 'ellipse', 'image'].includes(field.type) - ) { - return 'readonly/static visual field'; - } - - return 'unknown field'; -} - -export function getEffectiveMappedKeys( - fields: TemplateFieldRecord[], - mappings: Array<{ placeholderKey: string }> -) { - const mappedKeys = new Set(mappings.map((mapping) => mapping.placeholderKey)); - - for (const field of fields) { - if (!field.name || field.tokens.length !== 1) { - continue; - } - - const [token] = field.tokens; - - if (mappedKeys.has(field.name) && !mappedKeys.has(token)) { - mappedKeys.add(token); - } - - if (mappedKeys.has(token) && !mappedKeys.has(field.name)) { - mappedKeys.add(field.name); - } - } - - return mappedKeys; -} - -export function normalizeTemplateInputAliases( - fields: TemplateFieldRecord[], - templateInput: Record -) { - const normalized = { ...templateInput }; - - for (const field of fields) { - if (!field.name || field.tokens.length !== 1) { - continue; - } - - const [token] = field.tokens; - - if (!token || field.name === token) { - continue; - } - - if (normalized[field.name] !== undefined && normalized[token] === undefined) { - normalized[token] = normalized[field.name]; - } - - if (normalized[token] !== undefined && normalized[field.name] === undefined) { - normalized[field.name] = normalized[token]; - } - } - - return normalized; -} - -export function loadTemplateSourceByOrganizationName( - organizationName: string, - variant: TemplateSourceVariant = 'legacy', -) { - const normalized = organizationName.toUpperCase(); - const fileName = normalized.includes('ONVALLA') - ? ( - variant === 'product-v1' - ? 'ONVALLA_template_pdfme_product_v1.json' - : 'ONVALLA_template_pdfme_fainal3.json' - ) - : normalized.includes('ALLA') - ? ( - variant === 'product-v1' - ? 'ALLA_template_pdfme_product_v1.json' - : 'ALLA_template_pdfme_fainal3.json' - ) - : null; - - if (!fileName) { - return null; - } - - const filePath = path.resolve(process.cwd(), 'src', 'pdfme_template', fileName); - return { - fileName, - relativePath: `src/pdfme_template/${fileName}`, - absolutePath: filePath, - schemaJson: JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown - }; -} - -export function loadTemplateSourceByRelativePath(relativePath: string | null | undefined) { - if (!relativePath) { - return null; - } - - const normalizedRelativePath = relativePath.replaceAll('\\', '/'); - const absolutePath = path.resolve(process.cwd(), normalizedRelativePath); - - if (!fs.existsSync(absolutePath)) { - return null; - } - - return { - fileName: path.basename(normalizedRelativePath), - relativePath: normalizedRelativePath, - absolutePath, - schemaJson: JSON.parse(fs.readFileSync(absolutePath, 'utf8')) as unknown - }; -} - -export function computeFieldDiff( - currentFields: TemplateFieldRecord[], - previousFields: TemplateFieldRecord[] -) { - const currentFieldNames = new Set(currentFields.map((field) => field.name).filter(Boolean)); - const previousFieldNames = new Set(previousFields.map((field) => field.name).filter(Boolean)); - - return { - deletedFields: [...previousFieldNames].filter((name) => !currentFieldNames.has(name)).sort(), - newFields: [...currentFieldNames].filter((name) => !previousFieldNames.has(name)).sort() - }; -} - -export function buildSupportedMappingsForFieldNames( - fieldNames: Set, - tokenNames: Set = new Set() -) { - return SUPPORTED_TEMPLATE_MAPPING_SEEDS.filter( - (mapping) => fieldNames.has(mapping.placeholderKey) || tokenNames.has(mapping.placeholderKey) - ); -} - -export function bumpMinorTemplateVersion(version: string) { - const [majorPart, minorPart = '0'] = version.split('.'); - const major = Number(majorPart); - const minor = Number(minorPart); - - if (!Number.isInteger(major) || !Number.isInteger(minor)) { - return `${version}.1`; - } - - return `${major}.${minor + 1}`; -} - -function normalizeSnapshotPayload(snapshot: unknown) { - if (!snapshot) { - return null; - } - - if (typeof snapshot === 'string') { - try { - const parsed = JSON.parse(snapshot) as unknown; - return parsed && typeof parsed === 'object' ? (parsed as Record) : null; - } catch { - return null; - } - } - - return snapshot && typeof snapshot === 'object' ? (snapshot as Record) : null; -} - -export async function getActiveTemplateVersions(sql: SqlClient): Promise { - const rows = await sql` -select - t.id as "templateId", - t.template_name as "templateName", - t.organization_id as "organizationId", - t.document_type as "documentType", - t.product_type as "productType", - t.file_type as "fileType", - v.id as "versionId", - v.version, - v.file_path as "filePath", - v.schema_json as "schemaJson" - from crm_document_templates t - inner join crm_document_template_versions v - on v.template_id = t.id - where t.is_active = true - and v.is_active = true - and t.deleted_at is null - and v.deleted_at is null - order by t.organization_id asc, t.template_name asc, v.created_at asc - `; - - return castRows(rows); -} - -export async function getMappingsForVersion(sql: SqlClient, templateVersionId: string) { - const [mappings, columns] = await Promise.all([ - sql` - select - id, - organization_id as "organizationId", - template_version_id as "templateVersionId", - placeholder_key as "placeholderKey", - source_path as "sourcePath", - data_type as "dataType", - default_value as "defaultValue", - format_mask as "formatMask", - sort_order as "sortOrder" - from crm_document_template_mappings - where template_version_id = ${templateVersionId} - and deleted_at is null - order by sort_order asc, created_at asc - `, - sql` - select - mapping_id as "mappingId", - column_name as "columnName", - source_field as "sourceField", - sort_order as "sortOrder", - format_mask as "formatMask" - from crm_document_template_table_columns - where deleted_at is null - order by sort_order asc, created_at asc - ` - ]); - - return { - mappings: castRows(mappings), - columns: castRows(columns) - }; -} - -export async function findAuditQuotation(sql: SqlClient) { - const [fixtureQuotation] = await sql` - select - q.id, - q.code, - q.organization_id as "organizationId", - q.customer_id as "customerId", - q.contact_id as "contactId", - q.quotation_date as "quotationDate", - q.valid_until as "validUntil", - q.project_name as "projectName", - q.project_location as "projectLocation", - q.attention, - q.reference, - q.currency, - q.subtotal, - q.total_amount as "totalAmount", - currency_option.code as "currencyCode", - q.approved_template_version_id as "approvedTemplateVersionId", - q.salesman_id as "salesmanId", - q.created_by as "createdBy", - q.created_at as "createdAt", - q.approved_snapshot as "approvedSnapshot", - q.approved_pdf_url as "approvedPdfUrl" - from crm_quotations q - left join ms_options currency_option - on currency_option.id = q.currency - where q.code = ${AUDIT_QUOTATION_CODE} - and q.deleted_at is null - limit 1 - `; - - if (fixtureQuotation) { - return fixtureQuotation; - } - - const [fallbackQuotation] = await sql` - select - q.id, - q.code, - q.organization_id as "organizationId", - q.customer_id as "customerId", - q.contact_id as "contactId", - q.quotation_date as "quotationDate", - q.valid_until as "validUntil", - q.project_name as "projectName", - q.project_location as "projectLocation", - q.attention, - q.reference, - q.currency, - q.subtotal, - q.total_amount as "totalAmount", - currency_option.code as "currencyCode", - q.salesman_id as "salesmanId", - q.created_by as "createdBy", - q.created_at as "createdAt", - q.approved_snapshot as "approvedSnapshot", - q.approved_pdf_url as "approvedPdfUrl" - from crm_quotations q - left join ms_options currency_option - on currency_option.id = q.currency - where q.deleted_at is null - order by q.updated_at desc - limit 1 - `; - - if (!fallbackQuotation) { - throw new Error('No quotation found for PDF audit'); - } - - return fallbackQuotation; -} - -function getValueByPath(source: unknown, valuePath: string): unknown { - return valuePath.split('.').reduce((current, segment) => { - if (current === null || current === undefined) { - return undefined; - } - - if (Array.isArray(current) && /^\d+$/.test(segment)) { - return current[Number(segment)]; - } - - if (typeof current === 'object' && current !== null && segment in current) { - return (current as Record)[segment]; - } - - return undefined; - }, source); -} - -function formatPdfDate(dateString: string | Date | null | undefined) { - if (!dateString) { - return '-'; - } - - const date = dateString instanceof Date ? dateString : new Date(dateString); - - if (Number.isNaN(date.getTime())) { - return '-'; - } - - return date.toLocaleDateString('en-US', { - day: 'numeric', - month: 'short', - year: 'numeric' - }); -} - -function formatPdfCurrency(amount: number | string | null | undefined, currencyCode = 'THB') { - if (amount === null || amount === undefined) { - return '-'; - } - - const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', '')); - - if (!Number.isFinite(normalized)) { - return '-'; - } - - const symbols: Record = { - THB: 'THB ', - USD: '$', - EUR: 'EUR ' - }; - const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `; - return `${prefix}${new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }).format(normalized)}`.trim(); -} - -function formatPdfCurrencyAmount(amount: number | string | null | undefined, currencyCode = 'THB') { - if (amount === null || amount === undefined) { - return '-'; - } - - const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', '')); - - if (!Number.isFinite(normalized)) { - return '-'; - } - - const symbols: Record = { - THB: '฿', - USD: '$', - EUR: 'EUR ' - }; - const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `; - return `${prefix}${new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }).format(normalized)}`.trim(); -} - -function buildPdfPriceTableData(amount: number | string | null | undefined, currencyCode?: string | null) { - const normalizedCurrency = currencyCode?.trim() || 'THB'; - - return [[ - PRICE_TABLE_LABEL, - formatPdfCurrencyAmount(amount, normalizedCurrency), - ' ', - normalizedCurrency - ]]; -} - -function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: number | null }> = []) { - if (!items.length) { - return [['-']]; - } - - return [...items] - .sort((left, right) => (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER)) - .map((item) => [item.content?.trim() || '-']); -} - -function formatPdfDecimal(amount: number | string | null | undefined) { - if (amount === null || amount === undefined) { - return '-'; - } - - const normalized = - typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', '')); - - if (!Number.isFinite(normalized)) { - return '-'; - } - - return new Intl.NumberFormat('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 3 - }).format(normalized); -} - -function buildAuditItemsTable( - items: Array<{ - itemNumber?: number | null; - description?: string | null; - quantity?: number | null; - unitLabel?: string | null; - unitPrice?: number | null; - discount?: number | null; - totalPrice?: number | null; - notes?: string | null; - }>, - currencyCode?: string | null -) { - return items.map((item, index) => { - const description = [item.description?.trim(), item.notes?.trim()] - .filter((value): value is string => Boolean(value)) - .join('\n') - .trim(); - - return [ - String(item.itemNumber ?? index + 1), - description || '-', - formatPdfDecimal(item.quantity ?? null), - item.unitLabel?.trim() || '-', - formatPdfCurrency(item.unitPrice ?? null, currencyCode?.trim() || 'THB'), - formatPdfCurrency(item.discount ?? 0, currencyCode?.trim() || 'THB'), - formatPdfCurrency(item.totalPrice ?? null, currencyCode?.trim() || 'THB') - ]; - }); -} - -function applyFormatMask(value: unknown, formatMask?: string | null) { - if (value === null || value === undefined) { - return value; - } - - if (formatMask === 'currency' || formatMask?.startsWith('currency_')) { - const currencyCode = formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', ''); - return formatPdfCurrency(value as number | string, currencyCode); - } - - if (formatMask === 'date' || formatMask === 'date_en') { - return formatPdfDate(value as string); - } - - return value; -} - -function isBlankString(value: unknown) { - return typeof value === 'string' && value.trim().length === 0; -} - -function resolveScalarMappingValue( - rawValue: unknown, - formattedValue: unknown, - defaultValue?: string | null -) { - if (formattedValue !== undefined && formattedValue !== null && !isBlankString(formattedValue)) { - return formattedValue; - } - - if (rawValue !== undefined && rawValue !== null && !isBlankString(rawValue)) { - return rawValue; - } - - if (defaultValue !== undefined && defaultValue !== null && defaultValue.trim().length > 0) { - return defaultValue; - } - - return '-'; -} - -function normalizePdfmeTable( - value: unknown, - columns?: Array<{ sourceField: string; formatMask?: string | null }> -) { - if (!Array.isArray(value) || value.length === 0) { - return [['-']]; - } - - if (value.every((row) => Array.isArray(row))) { - return (value as unknown[][]).map((row) => - row.map((cell) => { - if (typeof cell === 'string') { - const trimmedValue = cell.trim(); - - if (trimmedValue) { - return trimmedValue; - } - - return cell.length > 0 ? cell : '-'; - } - - return String(cell ?? '-').trim() || '-'; - }) - ); - } - - if (!columns?.length) { - return value.map((row) => [String(row ?? '-').trim() || '-']); - } - - return value.map((row) => { - const rowRecord = row as Record; - return columns.map((column) => { - const formatted = applyFormatMask(rowRecord[column.sourceField], column.formatMask); - return String(formatted ?? '-').trim() || '-'; - }); - }); -} - -function mapDocumentDataToTemplateInput( - documentData: Record, - mappings: MappingRecord[], - columns: MappingColumnRecord[] -) { - const result: Record = {}; - const columnMap = new Map(); - - for (const column of columns) { - const items = columnMap.get(column.mappingId) ?? []; - items.push(column); - columnMap.set(column.mappingId, items); - } - - for (const mapping of mappings) { - const rawValue = getValueByPath(documentData, mapping.sourcePath); - - if (mapping.dataType === 'table') { - const mappingColumns = columnMap.get(mapping.id) ?? []; - let tableValue = rawValue; - - if (!Array.isArray(tableValue) && mapping.defaultValue) { - try { - const parsedDefault = JSON.parse(mapping.defaultValue) as unknown; - tableValue = Array.isArray(parsedDefault) ? parsedDefault : tableValue; - } catch { - // ignore invalid legacy table defaults - } - } - - result[mapping.placeholderKey] = normalizePdfmeTable(tableValue, mappingColumns); - continue; - } - - if (mapping.dataType === 'multiline') { - if (Array.isArray(rawValue)) { - result[mapping.placeholderKey] = rawValue - .map((item) => String(item ?? '').trim()) - .filter(Boolean) - .join('\n'); - continue; - } - - result[mapping.placeholderKey] = - typeof rawValue === 'string' && rawValue.trim().length > 0 - ? rawValue - : mapping.defaultValue ?? '-'; - continue; - } - - const formattedValue = applyFormatMask(rawValue, mapping.formatMask); - result[mapping.placeholderKey] = resolveScalarMappingValue( - rawValue, - formattedValue, - mapping.defaultValue - ); - } - - return result; -} - -function normalizeBusinessRoleLabel(businessRole: string | null | undefined) { - if (!businessRole) { - return '-'; - } - - return ( - SIGNATURE_ROLE_FALLBACK_LABELS[businessRole] ?? - businessRole - .split('_') - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' ') - ); -} - -function buildTopicInputs(topics: Array<{ topicType: string; items: Array<{ content: string; sortOrder: number }> }>) { - const sortedTopics = [...topics].sort((left, right) => left.topicType.localeCompare(right.topicType)); - const topicInputs: Record = {}; - - for (const [index, topic] of sortedTopics.entries()) { - topicInputs[`topic_1_${index}`] = [[topic.topicType || '-']]; - topicInputs[`item_topic_1_${index}`] = formatTopicItems(topic.items); - } - - return topicInputs; -} - -export async function buildAuditPayload(sql: SqlClient): Promise { - const quotation = await findAuditQuotation(sql); - const [organization, customer, contact, itemRows, topicRows, topicItemRows, partyRows, jobTitles] = - await Promise.all([ - sql` - select id, name - from organizations - where id = ${quotation.organizationId} - limit 1 - `.then((rows) => rows[0] ?? null), - sql` - select id, name, address, phone, email - from crm_customers - where id = ${quotation.customerId} - limit 1 - `.then((rows) => rows[0] ?? null), - quotation.contactId - ? sql` - select id, name, email, mobile, position - from crm_customer_contacts - where id = ${quotation.contactId} - limit 1 - `.then((rows) => rows[0] ?? null) - : Promise.resolve(null), - sql` - select - qi.id, - qi.item_number as "itemNumber", - qi.description, - qi.quantity, - qi.unit, - qi.unit_price as "unitPrice", - qi.discount, - qi.total_price as "totalPrice", - qi.sort_order as "sortOrder", - u.label as "unitLabel" - from crm_quotation_items qi - left join ms_options u - on u.id = qi.unit - where qi.quotation_id = ${quotation.id} - and qi.deleted_at is null - order by qi.sort_order asc, qi.item_number asc - `, - sql` - select - qt.id, - qt.title, - qt.sort_order as "sortOrder", - qt.topic_type as "topicTypeId", - o.code as "topicCode", - coalesce(o.label, qt.title) as "topicLabel" - from crm_quotation_topics qt - left join ms_options o - on o.id = qt.topic_type - where qt.quotation_id = ${quotation.id} - and qt.deleted_at is null - order by qt.sort_order asc, qt.created_at asc - `, - sql` - select - qti.id, - qti.topic_id as "topicId", - qti.content, - qti.sort_order as "sortOrder" - from crm_quotation_topic_items qti - where qti.deleted_at is null - and qti.topic_id in ( - select id - from crm_quotation_topics - where quotation_id = ${quotation.id} - and deleted_at is null - ) - order by qti.sort_order asc, qti.created_at asc - `, - sql` - select - qc.id, - qc.customer_id as "customerId", - qc.role, - qc.is_primary as "isPrimary" - from crm_quotation_customers qc - where qc.quotation_id = ${quotation.id} - and qc.deleted_at is null - `, - sql` - select code, label - from ms_options - where organization_id = ${quotation.organizationId} - and category = 'crm_job_title' - and is_active = true - and deleted_at is null - ` - ]); - - if (!organization || !customer) { - throw new Error('Audit quotation references missing organization or customer'); - } - - const activeTemplateVersions = await getActiveTemplateVersions(sql); - const templateVersion = activeTemplateVersions.find( - (item) => item.organizationId === quotation.organizationId && item.documentType === 'quotation' - ); - - if (!templateVersion) { - throw new Error(`No active quotation template version found for organization ${quotation.organizationId}`); - } - - const { mappings, columns } = await getMappingsForVersion(sql, templateVersion.versionId); - const topicItemsByTopicId = new Map>(); - - for (const topicItemRow of castRows<{ topicId: string; content: string; sortOrder: number }>( - topicItemRows - )) { - const items = topicItemsByTopicId.get(topicItemRow.topicId) ?? []; - items.push({ - content: topicItemRow.content, - sortOrder: topicItemRow.sortOrder - }); - topicItemsByTopicId.set(topicItemRow.topicId, items); - } - - const topics = (topicRows as Array>).map((topicRow) => ({ - id: String(topicRow.id), - title: String(topicRow.title ?? topicRow.topicLabel ?? 'Topic'), - topicCode: String(topicRow.topicCode ?? ''), - topicType: String(topicRow.topicLabel ?? topicRow.title ?? 'Topic'), - items: topicItemsByTopicId.get(String(topicRow.id)) ?? [] - })); - const exclusionTopic = topics.find((topic) => topic.topicCode === 'exclusion') ?? topics[0] ?? null; - const topicInputs = buildTopicInputs( - topics.map((topic) => ({ - topicType: topic.topicType, - items: topic.items - })) - ); - - const [approvalActions, userRows, membershipRows] = await Promise.all([ - sql` - select - aa.acted_by as "actedBy", - aa.acted_at as "actedAt", - aa.action, - aa.step_number as "stepNumber", - aps.role_code as "roleCode", - u.name as "actorName" - from crm_approval_actions aa - inner join crm_approval_requests ar - on ar.id = aa.approval_request_id - left join crm_approval_steps aps - on aps.workflow_id = ar.workflow_id - and aps.step_number = aa.step_number - and aps.deleted_at is null - left join users u - on u.id = aa.acted_by - where ar.entity_type = 'quotation' - and ar.entity_id = ${quotation.id} - and ar.organization_id = ${quotation.organizationId} - and aa.deleted_at is null - order by aa.acted_at asc, aa.created_at asc - `, - sql` - select id, name - from users - where id in (${quotation.salesmanId ?? quotation.createdBy}, ${quotation.createdBy}) - `, - sql` - select user_id as "userId", business_role as "businessRole" - from memberships - where organization_id = ${quotation.organizationId} - ` - ]); - const userMap = new Map( - castRows<{ id: string; name: string }>(userRows).map((row) => [row.id, row.name]) - ); - const membershipMap = new Map( - castRows<{ userId: string; businessRole: string }>(membershipRows).map((row) => [ - row.userId, - row.businessRole - ]) - ); - const jobTitleMap = new Map( - castRows<{ code: string; label: string }>(jobTitles).map((row) => [row.code, row.label]) - ); - const approvedActions = (approvalActions as Array>).filter( - (row) => row.action === 'approve' - ); - const finalApprovedAction = approvedActions.at(-1) ?? null; - const approvedByAction = - approvedActions.filter((row) => row.roleCode !== finalApprovedAction?.roleCode).at(-1) ?? null; - const preparedByUserId = (quotation.salesmanId as string | null) ?? (quotation.createdBy as string); - - const resolvePosition = (userId: string | null | undefined, roleCode?: string | null) => { - const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null; - return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-'; - }; - const currencyCode = - typeof quotation.currencyCode === 'string' && quotation.currencyCode.trim() - ? quotation.currencyCode.trim() - : 'THB'; - - const documentData: Record = { - company: { - id: organization.id, - name: organization.name - }, - customer: { - id: customer.id, - name: customer.name, - address: customer.address, - phone: customer.phone, - email: customer.email - }, - contact: contact - ? { - id: contact.id, - name: contact.name, - email: contact.email, - mobile: contact.mobile, - position: contact.position - } - : null, - quotation: { - id: quotation.id, - code: quotation.code, - quotationDate: quotation.quotationDate, - validUntil: quotation.validUntil, - projectName: quotation.projectName, - projectLocation: quotation.projectLocation, - attention: quotation.attention, - reference: quotation.reference, - currency: currencyCode, - currencyCode, - subtotal: quotation.subtotal, - totalAmount: quotation.totalAmount - }, - items: (itemRows as Array>).map((row) => ({ - id: row.id, - itemNumber: row.itemNumber, - description: row.description, - quantity: row.quantity, - unitLabel: row.unitLabel ?? row.unit ?? '-', - unitPrice: row.unitPrice, - discount: row.discount, - totalPrice: row.totalPrice - })), - quotationCustomers: partyRows, - topics: { - all: topics, - exclusion: exclusionTopic ? [exclusionTopic] : [] - }, - pdfme: { - labels: PDFME_STATIC_LABELS, - quotation_date: formatPdfDate(quotation.quotationDate as string), - quotation_price: formatPdfCurrency(quotation.subtotal as number, currencyCode), - quotation_price_data: buildPdfPriceTableData(quotation.subtotal as number, currencyCode), - exclusion_data: formatTopicItems(exclusionTopic?.items ?? []), - topic_inputs: topicInputs - }, - signatures: { - preparedBy: { - name: userMap.get(preparedByUserId) ?? '-', - position: resolvePosition(preparedByUserId), - actedAt: new Date(String(quotation.createdAt)).toISOString() - }, - approvedBy: { - name: String(approvedByAction?.actorName ?? '-'), - position: resolvePosition( - (approvedByAction?.actedBy as string | null | undefined) ?? null, - (approvedByAction?.roleCode as string | null | undefined) ?? null - ), - actedAt: approvedByAction?.actedAt ? new Date(String(approvedByAction.actedAt)).toISOString() : null - }, - authorizedBy: { - name: String(finalApprovedAction?.actorName ?? '-'), - position: resolvePosition( - (finalApprovedAction?.actedBy as string | null | undefined) ?? null, - (finalApprovedAction?.roleCode as string | null | undefined) ?? null - ), - actedAt: finalApprovedAction?.actedAt - ? new Date(String(finalApprovedAction.actedAt)).toISOString() - : null - } - } - }; - - const templateInput = { - ...mapDocumentDataToTemplateInput(documentData, mappings, columns), - ...resolveQuotationStaticTemplateInputs( - documentData as unknown as Parameters< - typeof resolveQuotationStaticTemplateInputs - >[0], - ), - ...topicInputs - }; -const templateFields = extractTemplateFields(templateVersion.schemaJson); -const hasItemsTableField = templateFields.some((field) => field.name === 'items_table'); -const normalizedTemplateInput = normalizeTemplateInputAliases( - templateFields, - { - ...templateInput, - ...(hasItemsTableField - ? { - items_table: buildAuditItemsTable( - (documentData as { items: Parameters[0] }).items, - (documentData as { quotation: { currencyCode?: string | null } }).quotation.currencyCode - ) - } - : {}), - } -); - - return { - organizationId: quotation.organizationId, - quotationId: quotation.id, - quotationCode: quotation.code, - templateVersionId: templateVersion.versionId, - approvedTemplateVersionId: - typeof quotation.approvedTemplateVersionId === 'string' - ? quotation.approvedTemplateVersionId - : null, - templateName: templateVersion.templateName, - templateVersion: templateVersion.version, - templateSchema: templateVersion.schemaJson, - mappings, - mappingColumns: columns, - documentData, - templateInput: normalizedTemplateInput, - topicInputs, - quotationMetrics: { - topicCount: topics.length, - itemCount: (itemRows as Array).length, - partyCount: (partyRows as Array).length - }, - approvedSnapshot: normalizeSnapshotPayload(quotation.approvedSnapshot), - approvedArtifactReference: - typeof quotation.approvedPdfUrl === 'string' ? quotation.approvedPdfUrl : null - }; -} - -export function summarizeIssues(statuses: Array<'PASS' | 'WARNING' | 'FAIL'>) { - if (statuses.includes('FAIL')) { - return 'FAIL'; - } - - if (statuses.includes('WARNING')) { - return 'WARNING'; - } - - return 'PASS'; -} - -function castRows(rows: unknown): T[] { - return rows as T[]; -} - -export interface ComputedSnapshotPayload { - quotationId: string; - approvedAt: string | null; - documentData: Record; - templateVersionId: string; - templateInput: Record; - generatedAt: string; - generatedBy: string; -} - -export function buildComputedSnapshotPayload( - payload: AuditPayload, - options?: { generatedAt?: string; generatedBy?: string } -): ComputedSnapshotPayload { - return { - quotationId: payload.quotationId, - approvedAt: - typeof (payload.documentData.approval as Record | undefined)?.approvedAt === - 'string' - ? ((payload.documentData.approval as Record).approvedAt as string) - : null, - documentData: payload.documentData, - templateVersionId: payload.templateVersionId, - templateInput: payload.templateInput, - generatedAt: options?.generatedAt ?? 'audit-generated', - generatedBy: options?.generatedBy ?? 'audit-suite' - }; -} - -export function getSnapshotTemplateInput(snapshot: Record | null) { - if (!snapshot) { - return null; - } - - return snapshot.templateInput && typeof snapshot.templateInput === 'object' - ? (snapshot.templateInput as Record) - : null; -} - -export function getSnapshotDocumentData(snapshot: Record | null) { - if (!snapshot) { - return null; - } - - return snapshot.documentData && typeof snapshot.documentData === 'object' - ? (snapshot.documentData as Record) - : null; -} +export * from '../src/features/foundation/pdf-audit/server/shared.ts'; diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/duplicate/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/duplicate/route.ts new file mode 100644 index 0000000..2e624f6 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/duplicate/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { duplicateDocumentTemplateVersion } from '@/features/foundation/document-template/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(_request: Request, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentTemplateCreate, + }); + const version = await duplicateDocumentTemplateVersion( + id, + organization.id, + session.user.id, + ); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_document_template_version', + entityId: version.id, + action: 'duplicate', + afterData: version, + }); + + return NextResponse.json({ + success: true, + message: 'Document template version duplicated successfully', + version, + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable duplicate document template version' }, + { status: 500 }, + ); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 5632808..0f8494d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -915,6 +915,7 @@ export const crmDocumentTemplateVersions = pgTable( version: text('version').notNull(), filePath: text('file_path'), schemaJson: jsonb('schema_json').notNull(), + metadataJson: jsonb('metadata_json'), previewImageUrl: text('preview_image_url'), isActive: boolean('is_active').default(true).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), diff --git a/src/features/foundation/document-template/components/template-version-management-panel.tsx b/src/features/foundation/document-template/components/template-version-management-panel.tsx index bc96227..53e43ec 100644 --- a/src/features/foundation/document-template/components/template-version-management-panel.tsx +++ b/src/features/foundation/document-template/components/template-version-management-panel.tsx @@ -14,10 +14,12 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { activateDocumentTemplateVersionMutation, archiveDocumentTemplateVersionMutation, compareDocumentTemplateVersionsMutation, + duplicateDocumentTemplateVersionMutation, previewDocumentTemplateVersionMutation, publishDocumentTemplateVersionMutation, rollbackDocumentTemplateVersionMutation, @@ -26,8 +28,10 @@ import { import { documentTemplateVersionManagementQueryOptions } from '../queries'; import type { DocumentTemplateVersionAuditSummary, + DocumentTemplateVersionCompareResponse, DocumentTemplateVersionDetail, DocumentTemplateVersionValidationSummary, + DocumentTemplateVersionVisualSummary, } from '../types'; function getStatusBadgeVariant(status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN') { @@ -66,12 +70,12 @@ function SummaryCard({ return (
{label}
-
{value}
+
{value}
); } -function IssueList({ +function StringList({ title, items, }: { @@ -96,28 +100,35 @@ function IssueList({ ); } -function RuntimeIssueList({ +function ValidationIssues({ validation, }: { validation: DocumentTemplateVersionValidationSummary | null | undefined; }) { - if (!validation?.runtimeIssues.length) { + if (!validation?.issues.length) { return null; } return (
-
Runtime Issues
+
Template Health Issues
- {validation.runtimeIssues.map((issue) => ( -
-
+ {validation.issues.map((issue) => ( +
+
{issue.severity} {issue.code} + {issue.location}
-
{issue.message}
+
{issue.message}
+ {issue.suggestedFix ? ( +
{issue.suggestedFix}
+ ) : null}
))}
@@ -125,6 +136,45 @@ function RuntimeIssueList({ ); } +function AuditSummary({ + audit, + visual, +}: { + audit: DocumentTemplateVersionAuditSummary | null; + visual: DocumentTemplateVersionVisualSummary | null | undefined; +}) { + if (!audit) { + return ( +
+ No audit summary available. +
+ ); + } + + return ( +
+ {audit.status}} + /> + + {audit.visualRegressionStatus} + + } + /> + + + + + + +
+ ); +} + function CompareDialog({ open, onOpenChange, @@ -141,6 +191,10 @@ function CompareDialog({ const [rightVersionId, setRightVersionId] = React.useState( versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id, ); + const [comparison, setComparison] = React.useState< + DocumentTemplateVersionCompareResponse['comparison'] | null + >(null); + const compareMutation = useMutation({ ...compareDocumentTemplateVersionsMutation, onError: (error) => @@ -149,6 +203,7 @@ function CompareDialog({ React.useEffect(() => { if (open) { + setComparison(null); setRightVersionId( versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id, ); @@ -161,29 +216,21 @@ function CompareDialog({ leftVersionId: currentVersion.id, rightVersionId, }); - - const metadataCount = result.comparison.metadataChanges.length; - const placeholderCount = - result.comparison.placeholderOnlyLeft.length + - result.comparison.placeholderOnlyRight.length; - const mappingCount = result.comparison.mappingDifferences.length; - - toast.success( - `Compare complete: ${metadataCount} metadata, ${placeholderCount} placeholders, ${mappingCount} mappings`, - ); - onOpenChange(false); + setComparison(result.comparison); } return ( - + Compare Versions - Compare current version against another version in the same template family. + Review metadata, placeholders, mappings, schema fields, and health state + without opening raw JSON manually. -
+ +
Base Version
{currentVersion.version}
@@ -205,6 +252,123 @@ function CompareDialog({
+ + {comparison ? ( + + + Metadata + Placeholders + Mappings + Schema + Health + + + + {comparison.metadataChanges.length ? ( + comparison.metadataChanges.map((change) => ( +
+
{change.field}
+
+ Left: {String(change.left ?? 'null')} +
+
+ Right: {String(change.right ?? 'null')} +
+
+ )) + ) : ( +
+ No metadata differences detected. +
+ )} +
+ + + + + {!comparison.placeholderOnlyLeft.length && + !comparison.placeholderOnlyRight.length ? ( +
+ Placeholder sets are identical. +
+ ) : null} +
+ + + {comparison.mappingDifferences.length ? ( + comparison.mappingDifferences.map((difference) => ( +
+
{difference.placeholderKey}
+
+ {difference.difference} +
+
+ )) + ) : ( +
+ No mapping differences detected. +
+ )} +
+ + + + + {comparison.fieldSchemaChanges.changedFields.length ? ( +
+
Changed Field Types
+ {comparison.fieldSchemaChanges.changedFields.map((item) => ( +
+ {item.field}: {item.difference} +
+ ))} +
+ ) : null} + {comparison.jsonDiff.length ? ( +
+
JSON Differences
+ {comparison.jsonDiff.map((item) => ( +
+ {item.path}: {String(item.left ?? 'null')} {'->'}{' '} + {String(item.right ?? 'null')} +
+ ))} +
+ ) : null} +
+ + +
+ + +
+ + +
+
+ ) : null} +
+
+
- - - + + - + + +
) : (
@@ -456,7 +618,7 @@ export function TemplateVersionManagementPanel({
Audit Summary
- {renderAuditSummary(audit)} +
diff --git a/src/features/foundation/document-template/mutations.ts b/src/features/foundation/document-template/mutations.ts index deda8e7..e7ddb1b 100644 --- a/src/features/foundation/document-template/mutations.ts +++ b/src/features/foundation/document-template/mutations.ts @@ -9,6 +9,7 @@ import { createDocumentTemplateVersion, deleteDocumentTemplate, deleteDocumentTemplateMapping, + duplicateDocumentTemplateVersion, previewDocumentTemplateVersion, publishDocumentTemplateVersion, rollbackDocumentTemplateVersion, @@ -254,6 +255,25 @@ export const archiveDocumentTemplateVersionMutation = mutationOptions({ }, }); +export const duplicateDocumentTemplateVersionMutation = mutationOptions({ + mutationFn: ({ + templateId, + versionId, + }: { + templateId: string; + versionId: string; + }) => duplicateDocumentTemplateVersion(versionId), + onSettled: async (_data, error, variables) => { + if (!error) { + await Promise.all([ + invalidateTemplateLists(), + invalidateTemplateDetail(variables.templateId), + invalidateVersionManagement(variables.versionId), + ]); + } + }, +}); + export const previewDocumentTemplateVersionMutation = mutationOptions({ mutationFn: (versionId: string) => previewDocumentTemplateVersion(versionId), }); diff --git a/src/features/foundation/document-template/server/management-service.ts b/src/features/foundation/document-template/server/management-service.ts index 594897d..87f4e67 100644 --- a/src/features/foundation/document-template/server/management-service.ts +++ b/src/features/foundation/document-template/server/management-service.ts @@ -11,20 +11,6 @@ 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, @@ -33,27 +19,30 @@ import { getEffectiveMappedKeys, normalizeTemplateInputAliases, summarizeIssues, -} from '../../../../../scripts/pdf-audit-utils'; +} from '@/features/foundation/pdf-audit/server/shared'; +import { + getDocumentTemplate, + mapDocumentDataToTemplateInput, + resolveTemplateMappings, +} from './service'; +import { + CURRENT_TEMPLATE_RUNTIME_VERSION, + getManagementMetadata, + mergeManagementMetadata, + sanitizeTemplateSchemaJson, +} from './metadata'; +import type { + DocumentTemplateDetail, + DocumentTemplateLifecycleStatus, + DocumentTemplateVersionAuditSummary, + DocumentTemplateVersionCompareResponse, + DocumentTemplateVersionDetail, + DocumentTemplateVersionPreviewResponse, + DocumentTemplateVersionVisualSummary, + DocumentTemplateVersionValidationSummary, +} from '../types'; type VersionRow = typeof crmDocumentTemplateVersions.$inferSelect; - -type 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', @@ -63,38 +52,14 @@ const REQUIRED_TEMPLATE_PLACEHOLDERS: string[] = [ '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'; + return getManagementMetadata({ + metadataJson: row.metadataJson, + schemaJson: row.schemaJson, + }).lifecycleStatus ?? 'draft'; } async function getVersionRow(versionId: string, organizationId: string) { @@ -136,7 +101,10 @@ function decorateVersionDetail(args: { auditSummary: DocumentTemplateVersionAuditSummary | null; validationSummary?: DocumentTemplateVersionValidationSummary | null; }): DocumentTemplateVersionDetail { - const metadata = getManagementMetadata(args.version.schemaJson); + const metadata = getManagementMetadata({ + metadataJson: args.version.metadataJson, + schemaJson: args.version.schemaJson, + }); const siblingIds = args.siblings.map((item) => item.id); const currentIndex = siblingIds.indexOf(args.version.id); @@ -168,8 +136,9 @@ function decorateVersionDetail(args: { (currentIndex >= 0 && currentIndex < siblingIds.length - 1 ? siblingIds[currentIndex + 1] : null), - auditSummary: args.auditSummary, - validationSummary: args.validationSummary ?? null, + auditSummary: args.auditSummary ?? metadata.auditSummary ?? null, + validationSummary: args.validationSummary ?? metadata.validationSummary ?? null, + visualSummary: metadata.visualSummary ?? null, }; } @@ -217,6 +186,40 @@ async function loadAuditSummary( return auditSummary; } +async function loadVisualSummary(): Promise { + const visualReportPath = path.resolve( + process.cwd(), + 'artifacts/pdf-visual/visual-regression-report.json', + ); + + try { + const visualReport = JSON.parse(await readFile(visualReportPath, 'utf8')) as { + generatedAt?: string; + overallStatus?: 'PASS' | 'WARNING' | 'FAIL'; + comparisons?: Array<{ changedPages?: number; diffCount?: number }>; + }; + + const comparisons = Array.isArray(visualReport.comparisons) + ? visualReport.comparisons + : []; + + return { + status: visualReport.overallStatus ?? 'NOT_RUN', + generatedAt: visualReport.generatedAt ?? null, + changedPages: comparisons.reduce( + (sum, item) => sum + (item.changedPages ?? 0), + 0, + ), + layoutDifferences: comparisons.reduce( + (sum, item) => sum + (item.diffCount ?? 0), + 0, + ), + }; + } catch { + return null; + } +} + async function buildFixtureDocumentData(): Promise { const sql = createSqlClient(); try { @@ -252,10 +255,7 @@ export async function validateDocumentTemplateVersion( versionId: string, organizationId: string, ): Promise { - const { template, version, siblings } = await getTemplateContext( - versionId, - organizationId, - ); + const { template, version } = 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)); @@ -285,6 +285,11 @@ export async function validateDocumentTemplateVersion( .map(([name]) => name) .sort(); + const orphanMappings = version.mappings + .filter((mapping) => !schemaFieldNames.has(mapping.placeholderKey)) + .map((mapping) => mapping.placeholderKey) + .sort(); + const unmappedPlaceholders = [...schemaFieldNames] .filter( (fieldName) => @@ -299,6 +304,30 @@ export async function validateDocumentTemplateVersion( ) .sort(); + const unsupportedSchemaTypes = fields + .filter( + (field) => + ![ + 'text', + 'line', + 'rectangle', + 'ellipse', + 'table', + 'image', + 'pdf', + ].includes(field.type), + ) + .map((field) => `${field.name}:${field.type}`) + .sort(); + + const unknownPlugins: string[] = []; + const sectionMarkerIssues: string[] = []; + if (hasProductSection && !schemaFieldNames.has('items_table')) { + sectionMarkerIssues.push( + 'Product section marker exists but items_table placeholder is missing', + ); + } + const documentData = await buildFixtureDocumentData(); const templateInput = mapDocumentDataToTemplateInput( documentData as unknown as Record, @@ -315,6 +344,74 @@ export async function validateDocumentTemplateVersion( templateInput: normalizedTemplateInput, }); + const issues: DocumentTemplateVersionValidationSummary['issues'] = []; + for (const field of requiredFieldsMissing) { + issues.push({ + code: 'missing_required_placeholder', + severity: 'error', + location: field, + message: `Required placeholder ${field} is missing`, + suggestedFix: 'Add the placeholder into the template schema.', + }); + } + for (const field of duplicateFields) { + issues.push({ + code: 'duplicate_field_name', + severity: 'error', + location: field, + message: `Field ${field} is duplicated in the template schema`, + suggestedFix: 'Rename or remove duplicated fields so each placeholder is unique.', + }); + } + for (const field of orphanMappings) { + issues.push({ + code: 'orphan_mapping', + severity: 'warning', + location: field, + message: `Mapping ${field} does not exist in the current schema`, + suggestedFix: 'Remove the mapping or restore the matching placeholder in the schema.', + }); + } + for (const field of unmappedPlaceholders) { + issues.push({ + code: 'unmapped_placeholder', + severity: 'warning', + location: field, + message: `Placeholder ${field} does not have a mapping`, + suggestedFix: 'Add a mapping or convert the field into a static designer label.', + }); + } + for (const field of unsupportedSchemaTypes) { + issues.push({ + code: 'unsupported_schema_type', + severity: 'error', + location: field, + message: `Unsupported schema field type detected: ${field}`, + suggestedFix: 'Use only supported PDFMe field types for runtime rendering.', + }); + } + for (const issue of sectionMarkerIssues) { + issues.push({ + code: 'section_marker_invalid', + severity: 'error', + location: 'items_table', + message: issue, + suggestedFix: 'Add items_table to the template and map it to quotation items.', + }); + } + for (const issue of runtime.issues) { + issues.push({ + code: issue.code, + severity: issue.severity, + location: 'runtime', + message: issue.message, + suggestedFix: + issue.severity === 'error' + ? 'Resolve runtime compatibility before publish.' + : 'Review runtime warning and confirm it is acceptable.', + }); + } + const statuses: Array<'PASS' | 'WARNING' | 'FAIL'> = []; if (requiredFieldsMissing.length) { statuses.push('FAIL'); @@ -322,19 +419,40 @@ export async function validateDocumentTemplateVersion( if (duplicateFields.length) { statuses.push('FAIL'); } + if (orphanMappings.length) { + statuses.push('WARNING'); + } if (unmappedPlaceholders.length) { statuses.push('WARNING'); } + if (unsupportedSchemaTypes.length || sectionMarkerIssues.length || unknownPlugins.length) { + statuses.push('FAIL'); + } statuses.push( ...runtime.issues.map((issue) => (issue.severity === 'error' ? 'FAIL' : 'WARNING')), ); + const status = summarizeIssues(statuses.length ? statuses : ['PASS']); + return { - status: summarizeIssues(statuses.length ? statuses : ['PASS']), + status, validatedAt: new Date().toISOString(), requiredFieldsMissing, duplicateFields, unmappedPlaceholders, + orphanMappings, + unsupportedSchemaTypes, + unknownPlugins, + sectionMarkerIssues, + healthStatus: + status === 'FAIL' ? 'fail' : status === 'WARNING' ? 'warning' : 'pass', + issues, + suggestedNextStep: + status === 'FAIL' + ? 'Fix blocking template issues before publish.' + : status === 'WARNING' + ? 'Review warnings and confirm template intent.' + : 'Template is ready for publish.', runtimeIssues: runtime.issues.map((issue) => ({ code: issue.code, severity: issue.severity, @@ -346,13 +464,15 @@ export async function validateDocumentTemplateVersion( async function persistVersionManagementMetadata(args: { versionId: string; organizationId: string; - schemaJson: unknown; + schemaJson?: unknown; + metadataJson?: unknown; isActive?: boolean; }) { const [updated] = await db .update(crmDocumentTemplateVersions) .set({ - schemaJson: args.schemaJson, + ...(args.schemaJson === undefined ? {} : { schemaJson: args.schemaJson }), + ...(args.metadataJson === undefined ? {} : { metadataJson: args.metadataJson }), ...(args.isActive === undefined ? {} : { isActive: args.isActive }), updatedAt: new Date(), }) @@ -373,8 +493,12 @@ export async function getManagedDocumentTemplateVersion( ) { const { version, siblings } = await getTemplateContext(versionId, organizationId); const auditSummary = await loadAuditSummary(version); + const visualSummary = await loadVisualSummary(); return decorateVersionDetail({ - version, + version: { + ...version, + visualSummary, + }, siblings, auditSummary, }); @@ -388,18 +512,30 @@ export async function validateAndPersistDocumentTemplateVersion(args: { args.versionId, args.organizationId, ); + const { version } = await getTemplateContext(args.versionId, args.organizationId); const row = await getVersionRow(args.versionId, args.organizationId); - const metadata = getManagementMetadata(row.schemaJson); + const metadata = getManagementMetadata({ + metadataJson: row.metadataJson, + schemaJson: row.schemaJson, + }); + const auditSummary = await loadAuditSummary(version); + const visualSummary = await loadVisualSummary(); if (validation.status !== 'FAIL') { await persistVersionManagementMetadata({ versionId: args.versionId, organizationId: args.organizationId, - schemaJson: withManagementMetadata(row.schemaJson, { - ...metadata, + metadataJson: mergeManagementMetadata({ + currentMetadataJson: row.metadataJson, + currentSchemaJson: row.schemaJson, + patch: { lifecycleStatus: inferLifecycleStatus(row) === 'draft' ? 'validated' : inferLifecycleStatus(row), validatedAt: validation.validatedAt, + validationSummary: validation, + auditSummary, + visualSummary, + }, }), }); } @@ -418,18 +554,25 @@ export async function publishDocumentTemplateVersion(args: { } const row = await getVersionRow(args.versionId, args.organizationId); - const metadata = getManagementMetadata(row.schemaJson); + const metadata = getManagementMetadata({ + metadataJson: row.metadataJson, + schemaJson: 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, + metadataJson: mergeManagementMetadata({ + currentMetadataJson: row.metadataJson, + currentSchemaJson: row.schemaJson, + patch: { + lifecycleStatus: 'published', + runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION, + publishedAt: new Date().toISOString(), + publishedBy: args.userId, + validatedAt: validation.validatedAt, + validationSummary: validation, + }, }), }); @@ -451,34 +594,48 @@ export async function activateDocumentTemplateVersion(args: { const currentActive = siblings.find((item) => item.isActive && item.id !== row.id) ?? null; if (currentActive) { - const previousMetadata = getManagementMetadata(currentActive.schemaJson); + const previousMetadata = getManagementMetadata({ + metadataJson: currentActive.metadataJson, + schemaJson: currentActive.schemaJson, + }); await persistVersionManagementMetadata({ versionId: currentActive.id, organizationId: args.organizationId, isActive: false, - schemaJson: withManagementMetadata(currentActive.schemaJson, { - ...previousMetadata, - lifecycleStatus: 'published', - nextVersionId: row.id, + metadataJson: mergeManagementMetadata({ + currentMetadataJson: currentActive.metadataJson, + currentSchemaJson: currentActive.schemaJson, + patch: { + ...previousMetadata, + lifecycleStatus: 'published', + nextVersionId: row.id, + }, }), }); } - const metadata = getManagementMetadata(row.schemaJson); + const metadata = getManagementMetadata({ + metadataJson: row.metadataJson, + schemaJson: 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, + schemaJson: sanitizeTemplateSchemaJson(row.schemaJson), + metadataJson: mergeManagementMetadata({ + currentMetadataJson: row.metadataJson, + currentSchemaJson: row.schemaJson, + patch: { + 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, + }, }), }); @@ -495,7 +652,10 @@ export async function rollbackDocumentTemplateVersion(args: { throw new AuthError('Rollback is only allowed from the current active version.', 409); } - const metadata = getManagementMetadata(row.schemaJson); + const metadata = getManagementMetadata({ + metadataJson: row.metadataJson, + schemaJson: row.schemaJson, + }); let targetVersionId = metadata.previousVersionId ?? null; if (!targetVersionId) { @@ -535,16 +695,22 @@ export async function archiveDocumentTemplateVersion(args: { throw new AuthError('Active versions must be rolled back before archive.', 409); } - const metadata = getManagementMetadata(row.schemaJson); + const metadata = getManagementMetadata({ + metadataJson: row.metadataJson, + schemaJson: row.schemaJson, + }); await persistVersionManagementMetadata({ versionId: row.id, organizationId: args.organizationId, isActive: false, - schemaJson: withManagementMetadata(row.schemaJson, { - ...metadata, - lifecycleStatus: 'archived', - archivedAt: new Date().toISOString(), - archivedBy: args.userId, + metadataJson: mergeManagementMetadata({ + currentMetadataJson: row.metadataJson, + currentSchemaJson: row.schemaJson, + patch: { + lifecycleStatus: 'archived', + archivedAt: new Date().toISOString(), + archivedBy: args.userId, + }, }), }); @@ -585,6 +751,10 @@ export async function compareDocumentTemplateVersions(args: { const leftPlaceholderSet = new Set(left.mappings.map((item) => item.placeholderKey)); const rightPlaceholderSet = new Set(right.mappings.map((item) => item.placeholderKey)); + const leftFields = extractTemplateFields(left.schemaJson); + const rightFields = extractTemplateFields(right.schemaJson); + const leftFieldNames = new Set(leftFields.map((field) => field.name).filter(Boolean)); + const rightFieldNames = new Set(rightFields.map((field) => field.name).filter(Boolean)); const mappingDifferences = [ ...left.mappings.flatMap((mapping) => { @@ -614,12 +784,52 @@ export async function compareDocumentTemplateVersions(args: { }); return currentSignature === otherSignature ? [] - : [{ placeholderKey: mapping.placeholderKey, difference: 'Mapping contract changed' }]; + : [{ + placeholderKey: mapping.placeholderKey, + difference: 'Mapping contract changed', + leftValue: currentSignature, + rightValue: otherSignature, + }]; }), ]; const leftValidation = await validateDocumentTemplateVersion(left.id, args.organizationId); const rightValidation = await validateDocumentTemplateVersion(right.id, args.organizationId); + const jsonDiff: DocumentTemplateVersionCompareResponse['comparison']['jsonDiff'] = []; + + const fieldSchemaChanges = { + addedFields: [...rightFieldNames].filter((field) => !leftFieldNames.has(field)).sort(), + removedFields: [...leftFieldNames].filter((field) => !rightFieldNames.has(field)).sort(), + changedFields: leftFields + .flatMap((leftField) => { + const rightField = rightFields.find((item) => item.name === leftField.name); + if (!rightField) { + return []; + } + if (leftField.type === rightField.type) { + return []; + } + return [ + { + field: leftField.name, + difference: `${leftField.type} -> ${rightField.type}`, + }, + ]; + }) + .sort((a, b) => a.field.localeCompare(b.field)), + }; + + const leftSchema = sanitizeTemplateSchemaJson(left.schemaJson) as Record; + const rightSchema = sanitizeTemplateSchemaJson(right.schemaJson) as Record; + const leftPages = Array.isArray(leftSchema.schemas) ? leftSchema.schemas.length : 0; + const rightPages = Array.isArray(rightSchema.schemas) ? rightSchema.schemas.length : 0; + if (leftPages !== rightPages) { + jsonDiff.push({ + path: 'schemas.length', + left: leftPages, + right: rightPages, + }); + } return { leftVersionId: left.id, @@ -628,10 +838,14 @@ export async function compareDocumentTemplateVersions(args: { placeholderOnlyLeft: [...leftPlaceholderSet].filter((item) => !rightPlaceholderSet.has(item)), placeholderOnlyRight: [...rightPlaceholderSet].filter((item) => !leftPlaceholderSet.has(item)), mappingDifferences, + fieldSchemaChanges, + jsonDiff, runtimeCompatibility: { leftStatus: leftValidation.status, rightStatus: rightValidation.status, }, + leftValidation, + rightValidation, }; } diff --git a/src/features/foundation/document-template/server/metadata.test.ts b/src/features/foundation/document-template/server/metadata.test.ts new file mode 100644 index 0000000..aa30027 --- /dev/null +++ b/src/features/foundation/document-template/server/metadata.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + getLegacyManagementMetadata, + getManagementMetadata, + mergeManagementMetadata, + sanitizeTemplateSchemaJson, +} from './metadata'; + +test('sanitizeTemplateSchemaJson removes embedded legacy management metadata', () => { + const schemaJson = { + basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] }, + schemas: [], + __templateManagement: { + lifecycleStatus: 'published', + runtimeVersion: 'p5', + }, + }; + + assert.deepEqual(sanitizeTemplateSchemaJson(schemaJson), { + basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] }, + schemas: [], + }); +}); + +test('getManagementMetadata falls back to legacy schema metadata when metadata_json is absent', () => { + const metadata = getManagementMetadata({ + schemaJson: { + schemas: [], + __templateManagement: { + lifecycleStatus: 'validated', + runtimeVersion: 'legacy-runtime', + }, + }, + }); + + assert.equal(metadata.lifecycleStatus, 'validated'); + assert.equal(metadata.runtimeVersion, 'legacy-runtime'); +}); + +test('mergeManagementMetadata prefers dedicated metadata_json but preserves compatibility fallback', () => { + const metadata = mergeManagementMetadata({ + currentMetadataJson: { + lifecycleStatus: 'draft', + runtimeVersion: 'p5.1', + }, + currentSchemaJson: { + __templateManagement: { + lifecycleStatus: 'published', + runtimeVersion: 'legacy-runtime', + }, + }, + patch: { + lifecycleStatus: 'active', + publishedBy: 'user-1', + }, + }); + + assert.equal(metadata.lifecycleStatus, 'active'); + assert.equal(metadata.runtimeVersion, 'p5.1'); + assert.equal(metadata.publishedBy, 'user-1'); +}); + +test('getLegacyManagementMetadata returns empty object for clean PDFMe schema', () => { + assert.deepEqual( + getLegacyManagementMetadata({ + basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] }, + schemas: [], + }), + {}, + ); +}); diff --git a/src/features/foundation/document-template/server/metadata.ts b/src/features/foundation/document-template/server/metadata.ts new file mode 100644 index 0000000..a5f6624 --- /dev/null +++ b/src/features/foundation/document-template/server/metadata.ts @@ -0,0 +1,86 @@ +import type { + DocumentTemplateLifecycleStatus, + DocumentTemplateVersionAuditSummary, + DocumentTemplateVersionValidationSummary, +} from '../types'; + +export const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5.1-template-management'; + +export interface DocumentTemplateVisualSummary { + status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN'; + generatedAt: string | null; + changedPages: number; + layoutDifferences: number; +} + +export interface DocumentTemplateManagementMetadata { + lifecycleStatus?: DocumentTemplateLifecycleStatus; + runtimeVersion?: string | null; + templateVariant?: string | null; + brand?: string | null; + publishedAt?: string | null; + publishedBy?: string | null; + activatedAt?: string | null; + activatedBy?: string | null; + archivedAt?: string | null; + archivedBy?: string | null; + previousVersionId?: string | null; + nextVersionId?: string | null; + validatedAt?: string | null; + validationSummary?: DocumentTemplateVersionValidationSummary | null; + auditSummary?: DocumentTemplateVersionAuditSummary | null; + visualSummary?: DocumentTemplateVisualSummary | null; +} + +function getSchemaRecord(schemaJson: unknown): Record { + return schemaJson && typeof schemaJson === 'object' + ? { ...(schemaJson as Record) } + : {}; +} + +export function getLegacyManagementMetadata( + schemaJson: unknown, +): DocumentTemplateManagementMetadata { + const schema = getSchemaRecord(schemaJson); + const metadata = schema.__templateManagement; + + return metadata && typeof metadata === 'object' + ? { ...(metadata as DocumentTemplateManagementMetadata) } + : {}; +} + +export function getManagementMetadata(args: { + metadataJson?: unknown; + schemaJson?: unknown; +}): DocumentTemplateManagementMetadata { + if (args.metadataJson && typeof args.metadataJson === 'object') { + return { ...(args.metadataJson as DocumentTemplateManagementMetadata) }; + } + + return getLegacyManagementMetadata(args.schemaJson); +} + +export function sanitizeTemplateSchemaJson(schemaJson: unknown): unknown { + const schema = getSchemaRecord(schemaJson); + + if (!('__templateManagement' in schema)) { + return schemaJson; + } + + const { __templateManagement: _management, ...sanitized } = schema; + return sanitized; +} + +export function mergeManagementMetadata(args: { + currentMetadataJson?: unknown; + currentSchemaJson?: unknown; + patch: DocumentTemplateManagementMetadata; +}): DocumentTemplateManagementMetadata { + return { + ...getManagementMetadata({ + metadataJson: args.currentMetadataJson, + schemaJson: args.currentSchemaJson, + }), + ...args.patch, + }; +} diff --git a/src/features/foundation/document-template/server/service.test.ts b/src/features/foundation/document-template/server/service.test.ts new file mode 100644 index 0000000..098fe83 --- /dev/null +++ b/src/features/foundation/document-template/server/service.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createDuplicateVersionLabel } from './service'; + +test('createDuplicateVersionLabel creates draft label first', () => { + const label = createDuplicateVersionLabel(['1.0', '2.0'], '2.0'); + assert.equal(label, '2.0-draft'); +}); + +test('createDuplicateVersionLabel increments copy suffix when draft already exists', () => { + const label = createDuplicateVersionLabel( + ['2.0', '2.0-draft', '2.0-copy-1'], + '2.0', + ); + assert.equal(label, '2.0-copy-2'); +}); diff --git a/src/features/foundation/document-template/server/service.ts b/src/features/foundation/document-template/server/service.ts index 16bff0e..05d8fd4 100644 --- a/src/features/foundation/document-template/server/service.ts +++ b/src/features/foundation/document-template/server/service.ts @@ -12,6 +12,12 @@ import { formatPdfDate, normalizePdfmeTable } from '@/features/crm/quotations/document/server/pdfme-transforms'; +import { + CURRENT_TEMPLATE_RUNTIME_VERSION, + getManagementMetadata, + mergeManagementMetadata, + sanitizeTemplateSchemaJson, +} from './metadata'; import type { DocumentTemplateDetail, DocumentTemplateFilters, @@ -30,54 +36,9 @@ 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; + metadata: ReturnType; }): DocumentTemplateLifecycleStatus { if (args.isActive) { return 'active'; @@ -108,7 +69,10 @@ function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): Docum function mapVersionRecord( row: typeof crmDocumentTemplateVersions.$inferSelect ): DocumentTemplateVersionRecord { - const metadata = getManagementMetadata(row.schemaJson); + const metadata = getManagementMetadata({ + metadataJson: row.metadataJson, + schemaJson: row.schemaJson, + }); return { id: row.id, @@ -116,7 +80,8 @@ function mapVersionRecord( templateId: row.templateId, version: row.version, filePath: row.filePath, - schemaJson: row.schemaJson, + schemaJson: sanitizeTemplateSchemaJson(row.schemaJson), + metadataJson: row.metadataJson ?? null, previewImageUrl: row.previewImageUrl, isActive: row.isActive, lifecycleStatus: inferLifecycleStatus({ @@ -502,6 +467,15 @@ export async function createDocumentTemplateVersion( payload: DocumentTemplateVersionMutationPayload ) { await assertTemplate(templateId, organizationId); + const sanitizedSchemaJson = sanitizeTemplateSchemaJson(payload.schemaJson); + const metadataJson = mergeManagementMetadata({ + patch: { + lifecycleStatus: payload.isActive ? 'active' : 'draft', + runtimeVersion: payload.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION, + templateVariant: payload.templateVariant ?? null, + brand: payload.brand ?? null, + }, + }); const [created] = await db .insert(crmDocumentTemplateVersions) .values({ @@ -510,9 +484,10 @@ export async function createDocumentTemplateVersion( templateId, version: payload.version.trim(), filePath: payload.filePath?.trim() || null, - schemaJson: payload.schemaJson, + schemaJson: sanitizedSchemaJson, + metadataJson, previewImageUrl: payload.previewImageUrl?.trim() || null, - isActive: payload.isActive ?? true, + isActive: payload.isActive ?? false, createdBy: userId }) .returning(); @@ -526,12 +501,28 @@ export async function updateDocumentTemplateVersion( payload: Partial ) { const current = await assertTemplateVersion(versionId, organizationId); + const currentMetadata = getManagementMetadata({ + metadataJson: current.metadataJson, + schemaJson: current.schemaJson, + }); const [updated] = await db .update(crmDocumentTemplateVersions) .set({ version: payload.version?.trim() ?? current.version, filePath: payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null, - schemaJson: payload.schemaJson ?? current.schemaJson, + schemaJson: + payload.schemaJson === undefined + ? sanitizeTemplateSchemaJson(current.schemaJson) + : sanitizeTemplateSchemaJson(payload.schemaJson), + metadataJson: mergeManagementMetadata({ + currentMetadataJson: current.metadataJson, + currentSchemaJson: current.schemaJson, + patch: { + runtimeVersion: payload.runtimeVersion ?? currentMetadata.runtimeVersion ?? null, + templateVariant: payload.templateVariant ?? currentMetadata.templateVariant ?? null, + brand: payload.brand ?? currentMetadata.brand ?? null, + }, + }), previewImageUrl: payload.previewImageUrl === undefined ? current.previewImageUrl @@ -545,6 +536,114 @@ export async function updateDocumentTemplateVersion( return updated; } +export function createDuplicateVersionLabel(existingVersions: string[], baseVersion: string) { + const normalizedBase = baseVersion.trim(); + const draftCandidate = `${normalizedBase}-draft`; + + if (!existingVersions.includes(draftCandidate)) { + return draftCandidate; + } + + let suffix = 1; + while (existingVersions.includes(`${normalizedBase}-copy-${suffix}`)) { + suffix += 1; + } + + return `${normalizedBase}-copy-${suffix}`; +} + +export async function duplicateDocumentTemplateVersion( + versionId: string, + organizationId: string, + userId: string, +) { + const sourceVersion = await assertTemplateVersion(versionId, organizationId); + const siblingVersions = await db + .select() + .from(crmDocumentTemplateVersions) + .where( + and( + eq(crmDocumentTemplateVersions.templateId, sourceVersion.templateId), + eq(crmDocumentTemplateVersions.organizationId, organizationId), + isNull(crmDocumentTemplateVersions.deletedAt), + ), + ) + .orderBy(asc(crmDocumentTemplateVersions.createdAt)); + const metadata = getManagementMetadata({ + metadataJson: sourceVersion.metadataJson, + schemaJson: sourceVersion.schemaJson, + }); + const duplicateId = crypto.randomUUID(); + const duplicateVersionLabel = createDuplicateVersionLabel( + siblingVersions.map((item) => item.version), + sourceVersion.version, + ); + + const [createdVersion] = await db + .insert(crmDocumentTemplateVersions) + .values({ + id: duplicateId, + organizationId, + templateId: sourceVersion.templateId, + version: duplicateVersionLabel, + filePath: sourceVersion.filePath, + schemaJson: sanitizeTemplateSchemaJson(sourceVersion.schemaJson), + metadataJson: { + ...metadata, + lifecycleStatus: 'draft', + publishedAt: null, + publishedBy: null, + activatedAt: null, + activatedBy: null, + archivedAt: null, + archivedBy: null, + previousVersionId: sourceVersion.id, + nextVersionId: null, + validationSummary: null, + auditSummary: null, + visualSummary: null, + }, + previewImageUrl: sourceVersion.previewImageUrl, + isActive: false, + createdBy: userId, + }) + .returning(); + + const mappings = await resolveTemplateMappings(sourceVersion.id, organizationId); + for (const mapping of mappings) { + const newMappingId = crypto.randomUUID(); + await db.insert(crmDocumentTemplateMappings).values({ + id: newMappingId, + organizationId, + templateVersionId: duplicateId, + placeholderKey: mapping.placeholderKey, + sourcePath: mapping.sourcePath, + dataType: mapping.dataType, + sheetName: mapping.sheetName, + defaultValue: mapping.defaultValue, + formatMask: mapping.formatMask, + sortOrder: mapping.sortOrder, + }); + + if (mapping.columns.length) { + await db.insert(crmDocumentTemplateTableColumns).values( + mapping.columns.map((column) => ({ + id: crypto.randomUUID(), + organizationId, + mappingId: newMappingId, + columnName: column.columnName, + sourceField: column.sourceField, + columnLetter: column.columnLetter, + sortOrder: column.sortOrder, + formatMask: column.formatMask, + })), + ); + } + } + + return createdVersion; +} + export async function setDocumentTemplateVersionActive( versionId: string, organizationId: string, diff --git a/src/features/foundation/document-template/service.ts b/src/features/foundation/document-template/service.ts index 3e6b7ef..5336414 100644 --- a/src/features/foundation/document-template/service.ts +++ b/src/features/foundation/document-template/service.ts @@ -145,6 +145,12 @@ export async function archiveDocumentTemplateVersion(id: string) { }); } +export async function duplicateDocumentTemplateVersion(id: string) { + return apiClient(`${BASE_PATH}/versions/${id}/duplicate`, { + method: 'POST', + }); +} + export async function previewDocumentTemplateVersion(id: string) { return apiClient(`${BASE_PATH}/versions/${id}/preview`); } diff --git a/src/features/foundation/document-template/types.ts b/src/features/foundation/document-template/types.ts index 1c32814..c195b68 100644 --- a/src/features/foundation/document-template/types.ts +++ b/src/features/foundation/document-template/types.ts @@ -5,6 +5,8 @@ export type DocumentTemplateLifecycleStatus = | 'active' | 'archived'; +export type TemplateHealthStatus = 'pass' | 'warning' | 'fail'; + export interface DocumentTemplateRecord { id: string; organizationId: string; @@ -28,6 +30,19 @@ export interface DocumentTemplateVersionValidationSummary { requiredFieldsMissing: string[]; duplicateFields: string[]; unmappedPlaceholders: string[]; + orphanMappings: string[]; + unsupportedSchemaTypes: string[]; + unknownPlugins: string[]; + sectionMarkerIssues: string[]; + suggestedNextStep?: string | null; + issues: Array<{ + code: string; + severity: 'warning' | 'error'; + location: string; + message: string; + suggestedFix: string | null; + }>; + healthStatus: TemplateHealthStatus; runtimeIssues: Array<{ code: string; severity: 'warning' | 'error'; @@ -43,6 +58,13 @@ export interface DocumentTemplateVersionAuditSummary { visualRegressionAt: string | null; } +export interface DocumentTemplateVersionVisualSummary { + status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN'; + generatedAt: string | null; + changedPages: number; + layoutDifferences: number; +} + export interface DocumentTemplateVersionRecord { id: string; organizationId: string; @@ -50,6 +72,7 @@ export interface DocumentTemplateVersionRecord { version: string; filePath: string | null; schemaJson: unknown; + metadataJson?: unknown; previewImageUrl: string | null; isActive: boolean; lifecycleStatus?: DocumentTemplateLifecycleStatus; @@ -130,6 +153,7 @@ export interface DocumentTemplateVersionDetail mappings: DocumentTemplateMappingWithColumns[]; validationSummary?: DocumentTemplateVersionValidationSummary | null; auditSummary?: DocumentTemplateVersionAuditSummary | null; + visualSummary?: DocumentTemplateVersionVisualSummary | null; } export interface DocumentTemplateDetail extends DocumentTemplateRecord { @@ -228,11 +252,28 @@ export interface DocumentTemplateVersionCompareResponse mappingDifferences: Array<{ placeholderKey: string; difference: string; + leftValue?: string | null; + rightValue?: string | null; + }>; + fieldSchemaChanges: { + addedFields: string[]; + removedFields: string[]; + changedFields: Array<{ + field: string; + difference: string; + }>; + }; + jsonDiff: Array<{ + path: string; + left: string | number | boolean | null; + right: string | number | boolean | null; }>; runtimeCompatibility: { leftStatus: 'PASS' | 'WARNING' | 'FAIL'; rightStatus: 'PASS' | 'WARNING' | 'FAIL'; }; + leftValidation: DocumentTemplateVersionValidationSummary; + rightValidation: DocumentTemplateVersionValidationSummary; }; } diff --git a/src/features/foundation/pdf-audit/server/shared.ts b/src/features/foundation/pdf-audit/server/shared.ts new file mode 100644 index 0000000..807d317 --- /dev/null +++ b/src/features/foundation/pdf-audit/server/shared.ts @@ -0,0 +1,1649 @@ +import fs from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import postgres from 'postgres'; +import { resolveQuotationStaticTemplateInputs } from '../../../crm/quotations/document/server/static-input-resolver.ts'; + +export const AUDIT_QUOTATION_CODE = 'QT-H5-AUDIT'; +export const AUDIT_ARTIFACTS_DIR = path.resolve( + /* turbopackIgnore: true */ process.cwd(), + 'artifacts', + 'pdf-audit', +); +export const REPORT_JSON_PATH = path.resolve( + /* turbopackIgnore: true */ process.cwd(), + 'docs/implementation/pdf-audit-report.json' +); +export const REPORT_MARKDOWN_PATH = path.resolve( + /* turbopackIgnore: true */ process.cwd(), + 'docs/implementation/pdf-audit-report.md' +); + +export type SqlClient = ReturnType; + +export interface TemplateFieldRecord { + pageIndex: number; + fieldIndex: number; + name: string; + type: string; + tokens: string[]; +} + +export interface ActiveTemplateVersionRecord { + templateId: string; + templateName: string; + organizationId: string; + documentType: string; + productType: string; + fileType: string; + versionId: string; + version: string; + filePath: string | null; + schemaJson: unknown; +} + +export interface MappingRecord { + id: string; + organizationId: string; + templateVersionId: string; + placeholderKey: string; + sourcePath: string; + dataType: 'scalar' | 'multiline' | 'table' | 'image'; + defaultValue: string | null; + formatMask: string | null; + sortOrder: number; +} + +export interface MappingColumnRecord { + mappingId: string; + columnName: string; + sourceField: string; + sortOrder: number; + formatMask: string | null; +} + +export type TemplateFieldClassification = + | 'static input field' + | 'dynamic topic template' + | 'readonly/static visual field' + | 'system field' + | 'unknown field'; + +export interface TemplateMappingSeed { + placeholderKey: string; + sourcePath: string; + dataType: 'scalar' | 'multiline' | 'table' | 'image'; + defaultValue?: string | null; + formatMask?: string | null; + sortOrder: number; + columns?: Array<{ + columnName: string; + sourceField: string; + columnLetter?: string; + sortOrder: number; + formatMask?: string | null; + }>; +} + +export interface AuditPayload { + organizationId: string; + quotationId: string; + quotationCode: string; + templateVersionId: string; + approvedTemplateVersionId: string | null; + templateName: string; + templateVersion: string; + templateSchema: unknown; + mappings: MappingRecord[]; + mappingColumns: MappingColumnRecord[]; + documentData: Record; + templateInput: Record; + topicInputs: Record; + quotationMetrics: { + topicCount: number; + itemCount: number; + partyCount: number; + }; + approvedSnapshot: Record | null; + approvedArtifactReference: string | null; +} + +const SYSTEM_FIELD_NAMES = new Set([ + '', + 'company1', + 'company', + 'company2', + 'company_addr1', + 'company_addr2', + 'company_tel', + 'company_email', + 'company_tax', + 'Please_do_not', + 'yours_faithfuly' +]); + +const DYNAMIC_TOPIC_FIELD_NAMES = new Set(['topic', 'data_topic']); +const LEGACY_DYNAMIC_SEED_TOKENS = new Set(['item_topic']); +const DYNAMIC_TOPIC_TOKEN_PREFIXES = ['topic_', 'item_topic_']; +const DESIGNER_LABEL_NAME_PATTERNS = [/_label$/i, /_lable$/i]; +const STATIC_VISUAL_FIELD_NAMES = new Set([ + 'colon', + 'colon copy', + 'colon_att', + 'colon_email', + 'colon_fax', + 'colon_project', + 'colon_site', + 'colon_tel', + 'dear_sirs', + 'line', + 'yours_faithfuly', + 'quotation_code_lable', + 'quotation_date_labe' +]); +export const PRICE_TABLE_LABEL = 'Price (Exclude VAT)'; +export const PDFME_STATIC_LABELS = { + tel: 'Tel', + email: 'Email', + att: 'Att', + project: 'Project', + location: 'Location' +} as const; +export const PDFME_STATIC_LABEL_FIELD_KEYS = [ + 'tel_label', + 'email_label', + 'att_label', + 'project_label', + 'site_label' +] as const; +export type TemplateSourceVariant = 'legacy' | 'product-v1'; +export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [ + { + placeholderKey: 'customer_name', + sourcePath: 'customer.name', + dataType: 'scalar', + sortOrder: 1 + }, + { + placeholderKey: 'customer_addr', + sourcePath: 'customer.address', + dataType: 'multiline', + sortOrder: 2 + }, + { + placeholderKey: 'customer_tel', + sourcePath: 'customer.phone', + dataType: 'scalar', + sortOrder: 3 + }, + { + placeholderKey: 'customer_email', + sourcePath: 'customer.email', + dataType: 'scalar', + sortOrder: 4 + }, + { + placeholderKey: 'customer_att', + sourcePath: 'quotation.attention', + dataType: 'scalar', + sortOrder: 5 + }, + { + placeholderKey: 'project_name', + sourcePath: 'quotation.projectName', + dataType: 'scalar', + sortOrder: 6 + }, + { + placeholderKey: 'site_location', + sourcePath: 'quotation.projectLocation', + dataType: 'multiline', + sortOrder: 7 + }, + { + placeholderKey: 'quotation_date_data', + sourcePath: 'pdfme.quotation_date', + dataType: 'scalar', + sortOrder: 8 + }, + { + placeholderKey: 'quotation_code_data', + sourcePath: 'quotation.code', + dataType: 'scalar', + sortOrder: 9 + }, + { + placeholderKey: 'quotation_price_data', + sourcePath: 'pdfme.quotation_price_data', + dataType: 'table', + defaultValue: JSON.stringify([[PRICE_TABLE_LABEL, '-', ' ', 'THB']]), + sortOrder: 10 + }, + { + placeholderKey: 'quotation_price', + sourcePath: 'pdfme.quotation_price', + dataType: 'scalar', + sortOrder: 11 + }, + { + placeholderKey: 'currency', + sourcePath: 'quotation.currency', + dataType: 'scalar', + sortOrder: 12 + }, + { + placeholderKey: 'exclusion_data', + sourcePath: 'pdfme.exclusion_data', + dataType: 'table', + sortOrder: 13 + }, + { + placeholderKey: 'tel_label', + sourcePath: 'pdfme.labels.tel', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.tel, + sortOrder: 14 + }, + { + placeholderKey: 'email_label', + sourcePath: 'pdfme.labels.email', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.email, + sortOrder: 15 + }, + { + placeholderKey: 'att_label', + sourcePath: 'pdfme.labels.att', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.att, + sortOrder: 16 + }, + { + placeholderKey: 'project_label', + sourcePath: 'pdfme.labels.project', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.project, + sortOrder: 17 + }, + { + placeholderKey: 'site_label', + sourcePath: 'pdfme.labels.location', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.location, + sortOrder: 18 + }, + { + placeholderKey: 'app1', + sourcePath: 'signatures.preparedBy.name', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 19 + }, + { + placeholderKey: 'app1_position', + sourcePath: 'signatures.preparedBy.position', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 20 + }, + { + placeholderKey: 'app2', + sourcePath: 'signatures.approvedBy.name', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 21 + }, + { + placeholderKey: 'app2_position', + sourcePath: 'signatures.approvedBy.position', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 22 + }, + { + placeholderKey: 'app3', + sourcePath: 'signatures.authorizedBy.name', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 23 + }, + { + placeholderKey: 'app3_position', + sourcePath: 'signatures.authorizedBy.position', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 24 + }, + { + placeholderKey: 'items_table', + sourcePath: 'items', + dataType: 'table', + sortOrder: 25, + columns: [ + { columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 }, + { columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 }, + { columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 }, + { columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 }, + { + columnName: 'Unit Price', + sourceField: 'unitPrice', + columnLetter: 'E', + sortOrder: 5, + formatMask: 'currency' + }, + { + columnName: 'Discount', + sourceField: 'discount', + columnLetter: 'F', + sortOrder: 6, + formatMask: 'currency' + }, + { + columnName: 'Total', + sourceField: 'totalPrice', + columnLetter: 'G', + sortOrder: 7, + formatMask: 'currency' + } + ] + } +] as const; +const SIGNATURE_ROLE_FALLBACK_LABELS: Record = { + sales: 'Sales Engineer', + sales_support: 'Sales Support', + sales_manager: 'Sales Manager', + department_manager: 'Department Manager', + top_manager: 'General Manager' +}; + +export function loadEnvFile(filePath: string) { + if (!fs.existsSync(filePath)) { + return; + } + + const content = fs.readFileSync(filePath, 'utf8'); + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + + if (!line || line.startsWith('#')) { + continue; + } + + const separatorIndex = line.indexOf('='); + + if (separatorIndex === -1) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (!(key in process.env)) { + process.env[key] = value; + } + } +} + +export function loadLocalEnv() { + loadEnvFile(path.resolve(/* turbopackIgnore: true */ process.cwd(), '.env.local')); + loadEnvFile(path.resolve(/* turbopackIgnore: true */ process.cwd(), '.env')); +} + +export function requireEnv(name: string) { + const value = process.env[name]?.trim(); + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +export function createSqlClient() { + loadLocalEnv(); + return postgres(requireEnv('DATABASE_URL'), { + prepare: false + }); +} + +export async function ensureAuditArtifactsDir() { + await mkdir(AUDIT_ARTIFACTS_DIR, { recursive: true }); +} + +export async function writeAuditArtifact(fileName: string, payload: unknown) { + await ensureAuditArtifactsDir(); + const targetPath = path.resolve(AUDIT_ARTIFACTS_DIR, fileName); + await writeFile(targetPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + return targetPath; +} + +export function computePayloadHash(value: unknown) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +export function extractTokensFromValue(value: unknown) { + const tokens = new Set(); + + if (typeof value !== 'string') { + return tokens; + } + + for (const match of value.matchAll(/\{([^}]+)\}/g)) { + tokens.add(match[1]); + } + + try { + const parsed = JSON.parse(value) as unknown; + + if (Array.isArray(parsed)) { + for (const row of parsed) { + if (!Array.isArray(row)) { + continue; + } + + for (const cell of row) { + if (typeof cell !== 'string') { + continue; + } + + for (const match of cell.matchAll(/\{([^}]+)\}/g)) { + tokens.add(match[1]); + } + } + } + } + } catch { + return tokens; + } + + return tokens; +} + +export function extractSinglePlaceholder(value: unknown) { + if (typeof value !== 'string') { + return null; + } + + const matches = [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]); + const uniqueMatches = [...new Set(matches)]; + + return uniqueMatches.length === 1 ? uniqueMatches[0] : null; +} + +function normalizeSchemaJson(schemaJson: unknown) { + if (typeof schemaJson === 'string') { + try { + return JSON.parse(schemaJson) as unknown; + } catch { + return null; + } + } + + return schemaJson; +} + +export function extractTemplateFields(schemaJson: unknown) { + const fields: TemplateFieldRecord[] = []; + const normalizedSchemaJson = normalizeSchemaJson(schemaJson); + + if ( + !normalizedSchemaJson || + typeof normalizedSchemaJson !== 'object' || + !('schemas' in normalizedSchemaJson) + ) { + return fields; + } + + const pages = (normalizedSchemaJson as { schemas?: unknown }).schemas; + + if (!Array.isArray(pages)) { + return fields; + } + + for (const [pageIndex, page] of pages.entries()) { + if (!Array.isArray(page)) { + continue; + } + + for (const [fieldIndex, field] of page.entries()) { + if (!field || typeof field !== 'object') { + continue; + } + + const typedField = field as { + name?: unknown; + type?: unknown; + content?: unknown; + head?: unknown; + }; + const tokens = new Set(); + + for (const token of extractTokensFromValue(typedField.content)) { + tokens.add(token); + } + + if (Array.isArray(typedField.head)) { + for (const value of typedField.head) { + for (const token of extractTokensFromValue(value)) { + tokens.add(token); + } + } + } + + fields.push({ + pageIndex, + fieldIndex, + name: typeof typedField.name === 'string' ? typedField.name : '', + type: typeof typedField.type === 'string' ? typedField.type : 'unknown', + tokens: [...tokens] + }); + } + } + + return fields; +} + +export function getDuplicateFieldNames(fields: TemplateFieldRecord[]) { + const fieldNameCounts = new Map(); + + for (const field of fields) { + if (!field.name) { + continue; + } + + fieldNameCounts.set(field.name, (fieldNameCounts.get(field.name) ?? 0) + 1); + } + + return [...fieldNameCounts.entries()] + .filter(([, count]) => count > 1) + .map(([name]) => name) + .sort(); +} + +export function getUnknownFieldNames( + fields: TemplateFieldRecord[], + mappedKeys: Set = new Set() +) { + return fields + .filter( + (field) => + field.name && classifyTemplateField(field, mappedKeys) === 'unknown field' + ) + .map((field) => field.name) + .sort(); +} + +export function isDesignerLabelField(field: TemplateFieldRecord) { + return ( + field.type === 'text' && + field.tokens.length === 0 && + DESIGNER_LABEL_NAME_PATTERNS.some((pattern) => pattern.test(field.name)) + ); +} + +export function classifyTemplateField( + field: TemplateFieldRecord, + mappedKeys: Set = new Set() +): TemplateFieldClassification { + if (!field.name) { + return 'system field'; + } + + if (SYSTEM_FIELD_NAMES.has(field.name)) { + return 'system field'; + } + + if (DYNAMIC_TOPIC_FIELD_NAMES.has(field.name)) { + return 'dynamic topic template'; + } + + if (mappedKeys.has(field.name)) { + return 'static input field'; + } + + if (isDesignerLabelField(field) || STATIC_VISUAL_FIELD_NAMES.has(field.name)) { + return 'readonly/static visual field'; + } + + if ( + field.tokens.length === 0 && + ['text', 'line', 'rectangle', 'ellipse', 'image'].includes(field.type) + ) { + return 'readonly/static visual field'; + } + + return 'unknown field'; +} + +export function getEffectiveMappedKeys( + fields: TemplateFieldRecord[], + mappings: Array<{ placeholderKey: string }> +) { + const mappedKeys = new Set(mappings.map((mapping) => mapping.placeholderKey)); + + for (const field of fields) { + if (!field.name || field.tokens.length !== 1) { + continue; + } + + const [token] = field.tokens; + + if (mappedKeys.has(field.name) && !mappedKeys.has(token)) { + mappedKeys.add(token); + } + + if (mappedKeys.has(token) && !mappedKeys.has(field.name)) { + mappedKeys.add(field.name); + } + } + + return mappedKeys; +} + +export function normalizeTemplateInputAliases( + fields: TemplateFieldRecord[], + templateInput: Record +) { + const normalized = { ...templateInput }; + + for (const field of fields) { + if (!field.name || field.tokens.length !== 1) { + continue; + } + + const [token] = field.tokens; + + if (!token || field.name === token) { + continue; + } + + if (normalized[field.name] !== undefined && normalized[token] === undefined) { + normalized[token] = normalized[field.name]; + } + + if (normalized[token] !== undefined && normalized[field.name] === undefined) { + normalized[field.name] = normalized[token]; + } + } + + return normalized; +} + +export function loadTemplateSourceByOrganizationName( + organizationName: string, + variant: TemplateSourceVariant = 'legacy', +) { + const normalized = organizationName.toUpperCase(); + const fileName = normalized.includes('ONVALLA') + ? ( + variant === 'product-v1' + ? 'ONVALLA_template_pdfme_product_v1.json' + : 'ONVALLA_template_pdfme_fainal3.json' + ) + : normalized.includes('ALLA') + ? ( + variant === 'product-v1' + ? 'ALLA_template_pdfme_product_v1.json' + : 'ALLA_template_pdfme_fainal3.json' + ) + : null; + + if (!fileName) { + return null; + } + + const filePath = path.resolve( + /* turbopackIgnore: true */ process.cwd(), + 'src', + 'pdfme_template', + fileName, + ); + return { + fileName, + relativePath: `src/pdfme_template/${fileName}`, + absolutePath: filePath, + schemaJson: JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown + }; +} + +export function loadTemplateSourceByRelativePath(relativePath: string | null | undefined) { + if (!relativePath) { + return null; + } + + const normalizedRelativePath = relativePath.replaceAll('\\', '/'); + const absolutePath = path.resolve( + /* turbopackIgnore: true */ process.cwd(), + normalizedRelativePath, + ); + + if (!fs.existsSync(absolutePath)) { + return null; + } + + return { + fileName: path.basename(normalizedRelativePath), + relativePath: normalizedRelativePath, + absolutePath, + schemaJson: JSON.parse(fs.readFileSync(absolutePath, 'utf8')) as unknown + }; +} + +export function computeFieldDiff( + currentFields: TemplateFieldRecord[], + previousFields: TemplateFieldRecord[] +) { + const currentFieldNames = new Set(currentFields.map((field) => field.name).filter(Boolean)); + const previousFieldNames = new Set(previousFields.map((field) => field.name).filter(Boolean)); + + return { + deletedFields: [...previousFieldNames].filter((name) => !currentFieldNames.has(name)).sort(), + newFields: [...currentFieldNames].filter((name) => !previousFieldNames.has(name)).sort() + }; +} + +export function buildSupportedMappingsForFieldNames( + fieldNames: Set, + tokenNames: Set = new Set() +) { + return SUPPORTED_TEMPLATE_MAPPING_SEEDS.filter( + (mapping) => fieldNames.has(mapping.placeholderKey) || tokenNames.has(mapping.placeholderKey) + ); +} + +export function bumpMinorTemplateVersion(version: string) { + const [majorPart, minorPart = '0'] = version.split('.'); + const major = Number(majorPart); + const minor = Number(minorPart); + + if (!Number.isInteger(major) || !Number.isInteger(minor)) { + return `${version}.1`; + } + + return `${major}.${minor + 1}`; +} + +function normalizeSnapshotPayload(snapshot: unknown) { + if (!snapshot) { + return null; + } + + if (typeof snapshot === 'string') { + try { + const parsed = JSON.parse(snapshot) as unknown; + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } + } + + return snapshot && typeof snapshot === 'object' ? (snapshot as Record) : null; +} + +export async function getActiveTemplateVersions(sql: SqlClient): Promise { + const rows = await sql` +select + t.id as "templateId", + t.template_name as "templateName", + t.organization_id as "organizationId", + t.document_type as "documentType", + t.product_type as "productType", + t.file_type as "fileType", + v.id as "versionId", + v.version, + v.file_path as "filePath", + v.schema_json as "schemaJson" + from crm_document_templates t + inner join crm_document_template_versions v + on v.template_id = t.id + where t.is_active = true + and v.is_active = true + and t.deleted_at is null + and v.deleted_at is null + order by t.organization_id asc, t.template_name asc, v.created_at asc + `; + + return castRows(rows); +} + +export async function getMappingsForVersion(sql: SqlClient, templateVersionId: string) { + const [mappings, columns] = await Promise.all([ + sql` + select + id, + organization_id as "organizationId", + template_version_id as "templateVersionId", + placeholder_key as "placeholderKey", + source_path as "sourcePath", + data_type as "dataType", + default_value as "defaultValue", + format_mask as "formatMask", + sort_order as "sortOrder" + from crm_document_template_mappings + where template_version_id = ${templateVersionId} + and deleted_at is null + order by sort_order asc, created_at asc + `, + sql` + select + mapping_id as "mappingId", + column_name as "columnName", + source_field as "sourceField", + sort_order as "sortOrder", + format_mask as "formatMask" + from crm_document_template_table_columns + where deleted_at is null + order by sort_order asc, created_at asc + ` + ]); + + return { + mappings: castRows(mappings), + columns: castRows(columns) + }; +} + +export async function findAuditQuotation(sql: SqlClient) { + const [fixtureQuotation] = await sql` + select + q.id, + q.code, + q.organization_id as "organizationId", + q.customer_id as "customerId", + q.contact_id as "contactId", + q.quotation_date as "quotationDate", + q.valid_until as "validUntil", + q.project_name as "projectName", + q.project_location as "projectLocation", + q.attention, + q.reference, + q.currency, + q.subtotal, + q.total_amount as "totalAmount", + currency_option.code as "currencyCode", + q.approved_template_version_id as "approvedTemplateVersionId", + q.salesman_id as "salesmanId", + q.created_by as "createdBy", + q.created_at as "createdAt", + q.approved_snapshot as "approvedSnapshot", + q.approved_pdf_url as "approvedPdfUrl" + from crm_quotations q + left join ms_options currency_option + on currency_option.id = q.currency + where q.code = ${AUDIT_QUOTATION_CODE} + and q.deleted_at is null + limit 1 + `; + + if (fixtureQuotation) { + return fixtureQuotation; + } + + const [fallbackQuotation] = await sql` + select + q.id, + q.code, + q.organization_id as "organizationId", + q.customer_id as "customerId", + q.contact_id as "contactId", + q.quotation_date as "quotationDate", + q.valid_until as "validUntil", + q.project_name as "projectName", + q.project_location as "projectLocation", + q.attention, + q.reference, + q.currency, + q.subtotal, + q.total_amount as "totalAmount", + currency_option.code as "currencyCode", + q.salesman_id as "salesmanId", + q.created_by as "createdBy", + q.created_at as "createdAt", + q.approved_snapshot as "approvedSnapshot", + q.approved_pdf_url as "approvedPdfUrl" + from crm_quotations q + left join ms_options currency_option + on currency_option.id = q.currency + where q.deleted_at is null + order by q.updated_at desc + limit 1 + `; + + if (!fallbackQuotation) { + throw new Error('No quotation found for PDF audit'); + } + + return fallbackQuotation; +} + +function getValueByPath(source: unknown, valuePath: string): unknown { + return valuePath.split('.').reduce((current, segment) => { + if (current === null || current === undefined) { + return undefined; + } + + if (Array.isArray(current) && /^\d+$/.test(segment)) { + return current[Number(segment)]; + } + + if (typeof current === 'object' && current !== null && segment in current) { + return (current as Record)[segment]; + } + + return undefined; + }, source); +} + +function formatPdfDate(dateString: string | Date | null | undefined) { + if (!dateString) { + return '-'; + } + + const date = dateString instanceof Date ? dateString : new Date(dateString); + + if (Number.isNaN(date.getTime())) { + return '-'; + } + + return date.toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric' + }); +} + +function formatPdfCurrency(amount: number | string | null | undefined, currencyCode = 'THB') { + if (amount === null || amount === undefined) { + return '-'; + } + + const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', '')); + + if (!Number.isFinite(normalized)) { + return '-'; + } + + const symbols: Record = { + THB: 'THB ', + USD: '$', + EUR: 'EUR ' + }; + const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `; + return `${prefix}${new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(normalized)}`.trim(); +} + +function formatPdfCurrencyAmount(amount: number | string | null | undefined, currencyCode = 'THB') { + if (amount === null || amount === undefined) { + return '-'; + } + + const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', '')); + + if (!Number.isFinite(normalized)) { + return '-'; + } + + const symbols: Record = { + THB: '฿', + USD: '$', + EUR: 'EUR ' + }; + const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `; + return `${prefix}${new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(normalized)}`.trim(); +} + +function buildPdfPriceTableData(amount: number | string | null | undefined, currencyCode?: string | null) { + const normalizedCurrency = currencyCode?.trim() || 'THB'; + + return [[ + PRICE_TABLE_LABEL, + formatPdfCurrencyAmount(amount, normalizedCurrency), + ' ', + normalizedCurrency + ]]; +} + +function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: number | null }> = []) { + if (!items.length) { + return [['-']]; + } + + return [...items] + .sort((left, right) => (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER)) + .map((item) => [item.content?.trim() || '-']); +} + +function formatPdfDecimal(amount: number | string | null | undefined) { + if (amount === null || amount === undefined) { + return '-'; + } + + const normalized = + typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', '')); + + if (!Number.isFinite(normalized)) { + return '-'; + } + + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 3 + }).format(normalized); +} + +function buildAuditItemsTable( + items: Array<{ + itemNumber?: number | null; + description?: string | null; + quantity?: number | null; + unitLabel?: string | null; + unitPrice?: number | null; + discount?: number | null; + totalPrice?: number | null; + notes?: string | null; + }>, + currencyCode?: string | null +) { + return items.map((item, index) => { + const description = [item.description?.trim(), item.notes?.trim()] + .filter((value): value is string => Boolean(value)) + .join('\n') + .trim(); + + return [ + String(item.itemNumber ?? index + 1), + description || '-', + formatPdfDecimal(item.quantity ?? null), + item.unitLabel?.trim() || '-', + formatPdfCurrency(item.unitPrice ?? null, currencyCode?.trim() || 'THB'), + formatPdfCurrency(item.discount ?? 0, currencyCode?.trim() || 'THB'), + formatPdfCurrency(item.totalPrice ?? null, currencyCode?.trim() || 'THB') + ]; + }); +} + +function applyFormatMask(value: unknown, formatMask?: string | null) { + if (value === null || value === undefined) { + return value; + } + + if (formatMask === 'currency' || formatMask?.startsWith('currency_')) { + const currencyCode = formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', ''); + return formatPdfCurrency(value as number | string, currencyCode); + } + + if (formatMask === 'date' || formatMask === 'date_en') { + return formatPdfDate(value as string); + } + + return value; +} + +function isBlankString(value: unknown) { + return typeof value === 'string' && value.trim().length === 0; +} + +function resolveScalarMappingValue( + rawValue: unknown, + formattedValue: unknown, + defaultValue?: string | null +) { + if (formattedValue !== undefined && formattedValue !== null && !isBlankString(formattedValue)) { + return formattedValue; + } + + if (rawValue !== undefined && rawValue !== null && !isBlankString(rawValue)) { + return rawValue; + } + + if (defaultValue !== undefined && defaultValue !== null && defaultValue.trim().length > 0) { + return defaultValue; + } + + return '-'; +} + +function normalizePdfmeTable( + value: unknown, + columns?: Array<{ sourceField: string; formatMask?: string | null }> +) { + if (!Array.isArray(value) || value.length === 0) { + return [['-']]; + } + + if (value.every((row) => Array.isArray(row))) { + return (value as unknown[][]).map((row) => + row.map((cell) => { + if (typeof cell === 'string') { + const trimmedValue = cell.trim(); + + if (trimmedValue) { + return trimmedValue; + } + + return cell.length > 0 ? cell : '-'; + } + + return String(cell ?? '-').trim() || '-'; + }) + ); + } + + if (!columns?.length) { + return value.map((row) => [String(row ?? '-').trim() || '-']); + } + + return value.map((row) => { + const rowRecord = row as Record; + return columns.map((column) => { + const formatted = applyFormatMask(rowRecord[column.sourceField], column.formatMask); + return String(formatted ?? '-').trim() || '-'; + }); + }); +} + +function mapDocumentDataToTemplateInput( + documentData: Record, + mappings: MappingRecord[], + columns: MappingColumnRecord[] +) { + const result: Record = {}; + const columnMap = new Map(); + + for (const column of columns) { + const items = columnMap.get(column.mappingId) ?? []; + items.push(column); + columnMap.set(column.mappingId, items); + } + + for (const mapping of mappings) { + const rawValue = getValueByPath(documentData, mapping.sourcePath); + + if (mapping.dataType === 'table') { + const mappingColumns = columnMap.get(mapping.id) ?? []; + let tableValue = rawValue; + + if (!Array.isArray(tableValue) && mapping.defaultValue) { + try { + const parsedDefault = JSON.parse(mapping.defaultValue) as unknown; + tableValue = Array.isArray(parsedDefault) ? parsedDefault : tableValue; + } catch { + // ignore invalid legacy table defaults + } + } + + result[mapping.placeholderKey] = normalizePdfmeTable(tableValue, mappingColumns); + continue; + } + + if (mapping.dataType === 'multiline') { + if (Array.isArray(rawValue)) { + result[mapping.placeholderKey] = rawValue + .map((item) => String(item ?? '').trim()) + .filter(Boolean) + .join('\n'); + continue; + } + + result[mapping.placeholderKey] = + typeof rawValue === 'string' && rawValue.trim().length > 0 + ? rawValue + : mapping.defaultValue ?? '-'; + continue; + } + + const formattedValue = applyFormatMask(rawValue, mapping.formatMask); + result[mapping.placeholderKey] = resolveScalarMappingValue( + rawValue, + formattedValue, + mapping.defaultValue + ); + } + + return result; +} + +function normalizeBusinessRoleLabel(businessRole: string | null | undefined) { + if (!businessRole) { + return '-'; + } + + return ( + SIGNATURE_ROLE_FALLBACK_LABELS[businessRole] ?? + businessRole + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + ); +} + +function buildTopicInputs(topics: Array<{ topicType: string; items: Array<{ content: string; sortOrder: number }> }>) { + const sortedTopics = [...topics].sort((left, right) => left.topicType.localeCompare(right.topicType)); + const topicInputs: Record = {}; + + for (const [index, topic] of sortedTopics.entries()) { + topicInputs[`topic_1_${index}`] = [[topic.topicType || '-']]; + topicInputs[`item_topic_1_${index}`] = formatTopicItems(topic.items); + } + + return topicInputs; +} + +export async function buildAuditPayload(sql: SqlClient): Promise { + const quotation = await findAuditQuotation(sql); + const [organization, customer, contact, itemRows, topicRows, topicItemRows, partyRows, jobTitles] = + await Promise.all([ + sql` + select id, name + from organizations + where id = ${quotation.organizationId} + limit 1 + `.then((rows) => rows[0] ?? null), + sql` + select id, name, address, phone, email + from crm_customers + where id = ${quotation.customerId} + limit 1 + `.then((rows) => rows[0] ?? null), + quotation.contactId + ? sql` + select id, name, email, mobile, position + from crm_customer_contacts + where id = ${quotation.contactId} + limit 1 + `.then((rows) => rows[0] ?? null) + : Promise.resolve(null), + sql` + select + qi.id, + qi.item_number as "itemNumber", + qi.description, + qi.quantity, + qi.unit, + qi.unit_price as "unitPrice", + qi.discount, + qi.total_price as "totalPrice", + qi.sort_order as "sortOrder", + u.label as "unitLabel" + from crm_quotation_items qi + left join ms_options u + on u.id = qi.unit + where qi.quotation_id = ${quotation.id} + and qi.deleted_at is null + order by qi.sort_order asc, qi.item_number asc + `, + sql` + select + qt.id, + qt.title, + qt.sort_order as "sortOrder", + qt.topic_type as "topicTypeId", + o.code as "topicCode", + coalesce(o.label, qt.title) as "topicLabel" + from crm_quotation_topics qt + left join ms_options o + on o.id = qt.topic_type + where qt.quotation_id = ${quotation.id} + and qt.deleted_at is null + order by qt.sort_order asc, qt.created_at asc + `, + sql` + select + qti.id, + qti.topic_id as "topicId", + qti.content, + qti.sort_order as "sortOrder" + from crm_quotation_topic_items qti + where qti.deleted_at is null + and qti.topic_id in ( + select id + from crm_quotation_topics + where quotation_id = ${quotation.id} + and deleted_at is null + ) + order by qti.sort_order asc, qti.created_at asc + `, + sql` + select + qc.id, + qc.customer_id as "customerId", + qc.role, + qc.is_primary as "isPrimary" + from crm_quotation_customers qc + where qc.quotation_id = ${quotation.id} + and qc.deleted_at is null + `, + sql` + select code, label + from ms_options + where organization_id = ${quotation.organizationId} + and category = 'crm_job_title' + and is_active = true + and deleted_at is null + ` + ]); + + if (!organization || !customer) { + throw new Error('Audit quotation references missing organization or customer'); + } + + const activeTemplateVersions = await getActiveTemplateVersions(sql); + const templateVersion = activeTemplateVersions.find( + (item) => item.organizationId === quotation.organizationId && item.documentType === 'quotation' + ); + + if (!templateVersion) { + throw new Error(`No active quotation template version found for organization ${quotation.organizationId}`); + } + + const { mappings, columns } = await getMappingsForVersion(sql, templateVersion.versionId); + const topicItemsByTopicId = new Map>(); + + for (const topicItemRow of castRows<{ topicId: string; content: string; sortOrder: number }>( + topicItemRows + )) { + const items = topicItemsByTopicId.get(topicItemRow.topicId) ?? []; + items.push({ + content: topicItemRow.content, + sortOrder: topicItemRow.sortOrder + }); + topicItemsByTopicId.set(topicItemRow.topicId, items); + } + + const topics = (topicRows as Array>).map((topicRow) => ({ + id: String(topicRow.id), + title: String(topicRow.title ?? topicRow.topicLabel ?? 'Topic'), + topicCode: String(topicRow.topicCode ?? ''), + topicType: String(topicRow.topicLabel ?? topicRow.title ?? 'Topic'), + items: topicItemsByTopicId.get(String(topicRow.id)) ?? [] + })); + const exclusionTopic = topics.find((topic) => topic.topicCode === 'exclusion') ?? topics[0] ?? null; + const topicInputs = buildTopicInputs( + topics.map((topic) => ({ + topicType: topic.topicType, + items: topic.items + })) + ); + + const [approvalActions, userRows, membershipRows] = await Promise.all([ + sql` + select + aa.acted_by as "actedBy", + aa.acted_at as "actedAt", + aa.action, + aa.step_number as "stepNumber", + aps.role_code as "roleCode", + u.name as "actorName" + from crm_approval_actions aa + inner join crm_approval_requests ar + on ar.id = aa.approval_request_id + left join crm_approval_steps aps + on aps.workflow_id = ar.workflow_id + and aps.step_number = aa.step_number + and aps.deleted_at is null + left join users u + on u.id = aa.acted_by + where ar.entity_type = 'quotation' + and ar.entity_id = ${quotation.id} + and ar.organization_id = ${quotation.organizationId} + and aa.deleted_at is null + order by aa.acted_at asc, aa.created_at asc + `, + sql` + select id, name + from users + where id in (${quotation.salesmanId ?? quotation.createdBy}, ${quotation.createdBy}) + `, + sql` + select user_id as "userId", business_role as "businessRole" + from memberships + where organization_id = ${quotation.organizationId} + ` + ]); + const userMap = new Map( + castRows<{ id: string; name: string }>(userRows).map((row) => [row.id, row.name]) + ); + const membershipMap = new Map( + castRows<{ userId: string; businessRole: string }>(membershipRows).map((row) => [ + row.userId, + row.businessRole + ]) + ); + const jobTitleMap = new Map( + castRows<{ code: string; label: string }>(jobTitles).map((row) => [row.code, row.label]) + ); + const approvedActions = (approvalActions as Array>).filter( + (row) => row.action === 'approve' + ); + const finalApprovedAction = approvedActions.at(-1) ?? null; + const approvedByAction = + approvedActions.filter((row) => row.roleCode !== finalApprovedAction?.roleCode).at(-1) ?? null; + const preparedByUserId = (quotation.salesmanId as string | null) ?? (quotation.createdBy as string); + + const resolvePosition = (userId: string | null | undefined, roleCode?: string | null) => { + const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null; + return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-'; + }; + const currencyCode = + typeof quotation.currencyCode === 'string' && quotation.currencyCode.trim() + ? quotation.currencyCode.trim() + : 'THB'; + + const documentData: Record = { + company: { + id: organization.id, + name: organization.name + }, + customer: { + id: customer.id, + name: customer.name, + address: customer.address, + phone: customer.phone, + email: customer.email + }, + contact: contact + ? { + id: contact.id, + name: contact.name, + email: contact.email, + mobile: contact.mobile, + position: contact.position + } + : null, + quotation: { + id: quotation.id, + code: quotation.code, + quotationDate: quotation.quotationDate, + validUntil: quotation.validUntil, + projectName: quotation.projectName, + projectLocation: quotation.projectLocation, + attention: quotation.attention, + reference: quotation.reference, + currency: currencyCode, + currencyCode, + subtotal: quotation.subtotal, + totalAmount: quotation.totalAmount + }, + items: (itemRows as Array>).map((row) => ({ + id: row.id, + itemNumber: row.itemNumber, + description: row.description, + quantity: row.quantity, + unitLabel: row.unitLabel ?? row.unit ?? '-', + unitPrice: row.unitPrice, + discount: row.discount, + totalPrice: row.totalPrice + })), + quotationCustomers: partyRows, + topics: { + all: topics, + exclusion: exclusionTopic ? [exclusionTopic] : [] + }, + pdfme: { + labels: PDFME_STATIC_LABELS, + quotation_date: formatPdfDate(quotation.quotationDate as string), + quotation_price: formatPdfCurrency(quotation.subtotal as number, currencyCode), + quotation_price_data: buildPdfPriceTableData(quotation.subtotal as number, currencyCode), + exclusion_data: formatTopicItems(exclusionTopic?.items ?? []), + topic_inputs: topicInputs + }, + signatures: { + preparedBy: { + name: userMap.get(preparedByUserId) ?? '-', + position: resolvePosition(preparedByUserId), + actedAt: new Date(String(quotation.createdAt)).toISOString() + }, + approvedBy: { + name: String(approvedByAction?.actorName ?? '-'), + position: resolvePosition( + (approvedByAction?.actedBy as string | null | undefined) ?? null, + (approvedByAction?.roleCode as string | null | undefined) ?? null + ), + actedAt: approvedByAction?.actedAt ? new Date(String(approvedByAction.actedAt)).toISOString() : null + }, + authorizedBy: { + name: String(finalApprovedAction?.actorName ?? '-'), + position: resolvePosition( + (finalApprovedAction?.actedBy as string | null | undefined) ?? null, + (finalApprovedAction?.roleCode as string | null | undefined) ?? null + ), + actedAt: finalApprovedAction?.actedAt + ? new Date(String(finalApprovedAction.actedAt)).toISOString() + : null + } + } + }; + + const templateInput = { + ...mapDocumentDataToTemplateInput(documentData, mappings, columns), + ...resolveQuotationStaticTemplateInputs( + documentData as unknown as Parameters< + typeof resolveQuotationStaticTemplateInputs + >[0], + ), + ...topicInputs + }; +const templateFields = extractTemplateFields(templateVersion.schemaJson); +const hasItemsTableField = templateFields.some((field) => field.name === 'items_table'); +const normalizedTemplateInput = normalizeTemplateInputAliases( + templateFields, + { + ...templateInput, + ...(hasItemsTableField + ? { + items_table: buildAuditItemsTable( + (documentData as { items: Parameters[0] }).items, + (documentData as { quotation: { currencyCode?: string | null } }).quotation.currencyCode + ) + } + : {}), + } +); + + return { + organizationId: quotation.organizationId, + quotationId: quotation.id, + quotationCode: quotation.code, + templateVersionId: templateVersion.versionId, + approvedTemplateVersionId: + typeof quotation.approvedTemplateVersionId === 'string' + ? quotation.approvedTemplateVersionId + : null, + templateName: templateVersion.templateName, + templateVersion: templateVersion.version, + templateSchema: templateVersion.schemaJson, + mappings, + mappingColumns: columns, + documentData, + templateInput: normalizedTemplateInput, + topicInputs, + quotationMetrics: { + topicCount: topics.length, + itemCount: (itemRows as Array).length, + partyCount: (partyRows as Array).length + }, + approvedSnapshot: normalizeSnapshotPayload(quotation.approvedSnapshot), + approvedArtifactReference: + typeof quotation.approvedPdfUrl === 'string' ? quotation.approvedPdfUrl : null + }; +} + +export function summarizeIssues(statuses: Array<'PASS' | 'WARNING' | 'FAIL'>) { + if (statuses.includes('FAIL')) { + return 'FAIL'; + } + + if (statuses.includes('WARNING')) { + return 'WARNING'; + } + + return 'PASS'; +} + +function castRows(rows: unknown): T[] { + return rows as T[]; +} + +export interface ComputedSnapshotPayload { + quotationId: string; + approvedAt: string | null; + documentData: Record; + templateVersionId: string; + templateInput: Record; + generatedAt: string; + generatedBy: string; +} + +export function buildComputedSnapshotPayload( + payload: AuditPayload, + options?: { generatedAt?: string; generatedBy?: string } +): ComputedSnapshotPayload { + return { + quotationId: payload.quotationId, + approvedAt: + typeof (payload.documentData.approval as Record | undefined)?.approvedAt === + 'string' + ? ((payload.documentData.approval as Record).approvedAt as string) + : null, + documentData: payload.documentData, + templateVersionId: payload.templateVersionId, + templateInput: payload.templateInput, + generatedAt: options?.generatedAt ?? 'audit-generated', + generatedBy: options?.generatedBy ?? 'audit-suite' + }; +} + +export function getSnapshotTemplateInput(snapshot: Record | null) { + if (!snapshot) { + return null; + } + + return snapshot.templateInput && typeof snapshot.templateInput === 'object' + ? (snapshot.templateInput as Record) + : null; +} + +export function getSnapshotDocumentData(snapshot: Record | null) { + if (!snapshot) { + return null; + } + + return snapshot.documentData && typeof snapshot.documentData === 'object' + ? (snapshot.documentData as Record) + : null; +}