diff --git a/.gitignore b/.gitignore index 54ebf32..f71a9f3 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,4 @@ dist .yarn/install-state.gz .pnp.* +.storage diff --git a/artifacts/pdf-audit/payload-parity-report.json b/artifacts/pdf-audit/payload-parity-report.json index 02db715..944007a 100644 --- a/artifacts/pdf-audit/payload-parity-report.json +++ b/artifacts/pdf-audit/payload-parity-report.json @@ -1,10 +1,10 @@ { "audit": "payload-parity", "overallStatus": "PASS", - "quotationCode": "CRA2606-1108", - "previewHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26", - "generationHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26", - "snapshotHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26", + "quotationCode": "CRA2606-1107", + "previewHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6", + "generationHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6", + "snapshotHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6", "mismatches": [], "comparableFieldCount": 31, "topicInputKeyCount": 6, diff --git a/artifacts/pdf-audit/runtime-payload-report.json b/artifacts/pdf-audit/runtime-payload-report.json index 46001f2..c8bab22 100644 --- a/artifacts/pdf-audit/runtime-payload-report.json +++ b/artifacts/pdf-audit/runtime-payload-report.json @@ -2,7 +2,7 @@ "audit": "runtime-payload", "overallStatus": "PASS", "staticLabelsStatus": "PASS", - "quotationCode": "CRA2606-1108", + "quotationCode": "CRA2606-1107", "templateName": "ALLA Demo Organization Quotation Standard", "templateVersion": "1.0", "findingCount": 168, @@ -25,28 +25,28 @@ "field": "documentData.customer.id", "source": "documentData", "status": "PASS", - "value": "85928568-22f2-424c-8896-a8e107ce3738", + "value": "11bf503b-9ae7-4f67-b800-2772fee2131c", "message": "String is populated" }, { "field": "documentData.customer.name", "source": "documentData", "status": "PASS", - "value": "มหาวิทยาลัยเทคโนโลยีพระจอมเกล้าพระนครเหนือ", + "value": "บริษัท นอร์ธเทิร์น แมชชีนเนอรี่ ดีลเลอร์ จำกัด", "message": "String is populated" }, { "field": "documentData.customer.address", "source": "documentData", "status": "PASS", - "value": "Bang Sue, Bangkok", + "value": "Mueang Chiang Mai, Chiang Mai", "message": "String is populated" }, { "field": "documentData.customer.phone", "source": "documentData", "status": "PASS", - "value": "02-400827", + "value": "02-400824", "message": "String is populated" }, { @@ -60,14 +60,14 @@ "field": "documentData.contact.id", "source": "documentData", "status": "PASS", - "value": "307bcf2f-46df-4ab7-b090-872b99338bf1", + "value": "a2cec2b0-23cc-4c49-8063-1d1fc9777cd0", "message": "String is populated" }, { "field": "documentData.contact.name", "source": "documentData", "status": "PASS", - "value": "คุณวราภรณ์ เกษมศรี", + "value": "คุณกิตติพงศ์ สุวรรณ", "message": "String is populated" }, { @@ -81,56 +81,56 @@ "field": "documentData.contact.mobile", "source": "documentData", "status": "PASS", - "value": "0810000250", + "value": "0810000220", "message": "String is populated" }, { "field": "documentData.contact.position", "source": "documentData", "status": "PASS", - "value": "Procurement Manager", + "value": "Plant Manager", "message": "String is populated" }, { "field": "documentData.quotation.id", "source": "documentData", "status": "PASS", - "value": "f9290e40-0836-431d-9d9f-0ff25fa2bcc3", + "value": "02d6b4b3-b11c-4a6e-9518-1258c8fcf4a1", "message": "String is populated" }, { "field": "documentData.quotation.code", "source": "documentData", "status": "PASS", - "value": "CRA2606-1108", + "value": "CRA2606-1107", "message": "String is populated" }, { "field": "documentData.quotation.projectName", "source": "documentData", "status": "PASS", - "value": "Material Handling Lab Crane", + "value": "Dealer Stock Crane Package", "message": "String is populated" }, { "field": "documentData.quotation.projectLocation", "source": "documentData", "status": "PASS", - "value": "Bang Sue Engineering Faculty", + "value": "Chiang Mai Service Warehouse", "message": "String is populated" }, { "field": "documentData.quotation.attention", "source": "documentData", "status": "PASS", - "value": "คุณวราภรณ์ เกษมศรี", + "value": "คุณกิตติพงศ์ สุวรรณ", "message": "String is populated" }, { "field": "documentData.quotation.reference", "source": "documentData", "status": "PASS", - "value": "crm-uat-seed-v1:university-lab-crane-approved", + "value": "crm-uat-seed-v1:dealer-channel-approved", "message": "String is populated" }, { @@ -151,14 +151,14 @@ "field": "documentData.quotation.subtotal", "source": "documentData", "status": "PASS", - "value": 3008000, + "value": 4418000, "message": "Scalar value is populated" }, { "field": "documentData.quotation.totalAmount", "source": "documentData", "status": "PASS", - "value": 3122003, + "value": 4585442, "message": "Scalar value is populated" }, { @@ -172,7 +172,7 @@ "field": "documentData.items[0].id", "source": "documentData", "status": "PASS", - "value": "2822652d-cab6-4b00-8b21-93a8b0c3d33f", + "value": "e150f933-729b-4ec1-bd3e-8514c1e05766", "message": "String is populated" }, { @@ -186,7 +186,7 @@ "field": "documentData.items[0].description", "source": "documentData", "status": "PASS", - "value": "Main equipment supply for Material Handling Lab Crane", + "value": "Main equipment supply for Dealer Stock Crane Package", "message": "String is populated" }, { @@ -207,7 +207,7 @@ "field": "documentData.items[0].unitPrice", "source": "documentData", "status": "PASS", - "value": 1717102, + "value": 2521993, "message": "Scalar value is populated" }, { @@ -221,14 +221,14 @@ "field": "documentData.items[0].totalPrice", "source": "documentData", "status": "PASS", - "value": 1717102, + "value": 2521993, "message": "Scalar value is populated" }, { "field": "documentData.items[1].id", "source": "documentData", "status": "PASS", - "value": "d080c242-c39b-4192-adab-f28fc0171389", + "value": "efcbc2e2-c561-478f-bfad-98dcbbd30ea6", "message": "String is populated" }, { @@ -242,7 +242,7 @@ "field": "documentData.items[1].description", "source": "documentData", "status": "PASS", - "value": "Installation and commissioning for Material Handling Lab Crane", + "value": "Installation and commissioning for Dealer Stock Crane Package", "message": "String is populated" }, { @@ -263,7 +263,7 @@ "field": "documentData.items[1].unitPrice", "source": "documentData", "status": "PASS", - "value": 874161, + "value": 1283924, "message": "Scalar value is populated" }, { @@ -277,14 +277,14 @@ "field": "documentData.items[1].totalPrice", "source": "documentData", "status": "PASS", - "value": 874161, + "value": 1283924, "message": "Scalar value is populated" }, { "field": "documentData.items[2].id", "source": "documentData", "status": "PASS", - "value": "de54b922-db69-42e3-a93c-940330852362", + "value": "d574bc39-ddd2-4f29-96fa-0091439c8abf", "message": "String is populated" }, { @@ -298,7 +298,7 @@ "field": "documentData.items[2].description", "source": "documentData", "status": "PASS", - "value": "Operator training and documentation for Material Handling Lab Crane", + "value": "Operator training and documentation for Dealer Stock Crane Package", "message": "String is populated" }, { @@ -319,7 +319,7 @@ "field": "documentData.items[2].unitPrice", "source": "documentData", "status": "PASS", - "value": 312200, + "value": 458544, "message": "Scalar value is populated" }, { @@ -333,14 +333,14 @@ "field": "documentData.items[2].totalPrice", "source": "documentData", "status": "PASS", - "value": 312200, + "value": 458544, "message": "Scalar value is populated" }, { "field": "documentData.items[3].id", "source": "documentData", "status": "PASS", - "value": "c903ac48-45c5-43b1-8948-1a196ec0d576", + "value": "0baeeec0-f2b0-4ea0-82d6-f80aa9de5aef", "message": "String is populated" }, { @@ -354,7 +354,7 @@ "field": "documentData.items[3].description", "source": "documentData", "status": "PASS", - "value": "Project management and testing for Material Handling Lab Crane", + "value": "Project management and testing for Dealer Stock Crane Package", "message": "String is populated" }, { @@ -375,7 +375,7 @@ "field": "documentData.items[3].unitPrice", "source": "documentData", "status": "PASS", - "value": 218540, + "value": 320981, "message": "Scalar value is populated" }, { @@ -389,7 +389,7 @@ "field": "documentData.items[3].totalPrice", "source": "documentData", "status": "PASS", - "value": 218540, + "value": 320981, "message": "Scalar value is populated" }, { @@ -403,14 +403,14 @@ "field": "documentData.quotationCustomers[0].id", "source": "documentData", "status": "PASS", - "value": "c15f26c7-2f23-44fc-84e2-0a435901f887", + "value": "81752ed4-508c-4946-9bd0-1450df258ffa", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].customerId", "source": "documentData", "status": "PASS", - "value": "85928568-22f2-424c-8896-a8e107ce3738", + "value": "11bf503b-9ae7-4f67-b800-2772fee2131c", "message": "String is populated" }, { @@ -438,7 +438,7 @@ "field": "documentData.topics.all[0].id", "source": "documentData", "status": "PASS", - "value": "396189b7-ca6c-4fc4-80dd-04f3f903f77b", + "value": "2f5b2486-9527-4a34-b5bc-319a76f26a97", "message": "String is populated" }, { @@ -473,7 +473,7 @@ "field": "documentData.topics.all[0].items[0].content", "source": "documentData", "status": "PASS", - "value": "Supply and execute material handling lab crane according to approved drawing.", + "value": "Supply and execute dealer stock crane package according to approved drawing.", "message": "String is populated" }, { @@ -501,7 +501,7 @@ "field": "documentData.topics.all[1].id", "source": "documentData", "status": "PASS", - "value": "41b30c9e-2adb-4407-b5f6-f803cc08bdb4", + "value": "6ea81a68-390f-4015-bd80-19f792eb57b6", "message": "String is populated" }, { @@ -564,7 +564,7 @@ "field": "documentData.topics.all[2].id", "source": "documentData", "status": "PASS", - "value": "1ef3d0b6-b722-42e1-b8c1-e328fbb1bb57", + "value": "2b9042e6-150f-4701-91c7-f490ff48de55", "message": "String is populated" }, { @@ -634,7 +634,7 @@ "field": "documentData.topics.exclusion[0].id", "source": "documentData", "status": "PASS", - "value": "41b30c9e-2adb-4407-b5f6-f803cc08bdb4", + "value": "6ea81a68-390f-4015-bd80-19f792eb57b6", "message": "String is populated" }, { @@ -732,14 +732,14 @@ "field": "documentData.pdfme.quotation_date", "source": "documentData", "status": "PASS", - "value": "Jun 15, 2026", + "value": "Jun 3, 2026", "message": "String is populated" }, { "field": "documentData.pdfme.quotation_price", "source": "documentData", "status": "PASS", - "value": "THB 3,008,000.00", + "value": "THB 4,418,000.00", "message": "String is populated" }, { @@ -816,70 +816,70 @@ "field": "documentData.signatures.preparedBy.actedAt", "source": "documentData", "status": "PASS", - "value": "2026-06-15T09:00:00.000Z", + "value": "2026-06-03T09:00:00.000Z", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.name", "source": "documentData", "status": "PASS", - "value": "-", + "value": "Sales Manager UAT", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.position", "source": "documentData", "status": "PASS", - "value": "-", + "value": "Sales Manager", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.actedAt", "source": "documentData", - "status": "WARNING", - "value": null, - "message": "Value is null" + "status": "PASS", + "value": "2026-06-06T10:00:00.000Z", + "message": "String is populated" }, { "field": "documentData.signatures.authorizedBy.name", "source": "documentData", "status": "PASS", - "value": "Sales Manager UAT", + "value": "CEO UAT", "message": "String is populated" }, { "field": "documentData.signatures.authorizedBy.position", "source": "documentData", "status": "PASS", - "value": "Sales Manager", + "value": "General Manager", "message": "String is populated" }, { "field": "documentData.signatures.authorizedBy.actedAt", "source": "documentData", "status": "PASS", - "value": "2026-06-17T11:00:00.000Z", + "value": "2026-06-07T09:00:00.000Z", "message": "String is populated" }, { "field": "templateInput.customer_name", "source": "templateInput", "status": "PASS", - "value": "มหาวิทยาลัยเทคโนโลยีพระจอมเกล้าพระนครเหนือ", + "value": "บริษัท นอร์ธเทิร์น แมชชีนเนอรี่ ดีลเลอร์ จำกัด", "message": "String is populated" }, { "field": "templateInput.customer_addr", "source": "templateInput", "status": "PASS", - "value": "Bang Sue, Bangkok", + "value": "Mueang Chiang Mai, Chiang Mai", "message": "String is populated" }, { "field": "templateInput.customer_tel", "source": "templateInput", "status": "PASS", - "value": "02-400827", + "value": "02-400824", "message": "String is populated" }, { @@ -893,35 +893,35 @@ "field": "templateInput.customer_att", "source": "templateInput", "status": "PASS", - "value": "คุณวราภรณ์ เกษมศรี", + "value": "คุณกิตติพงศ์ สุวรรณ", "message": "String is populated" }, { "field": "templateInput.project_name", "source": "templateInput", "status": "PASS", - "value": "Material Handling Lab Crane", + "value": "Dealer Stock Crane Package", "message": "String is populated" }, { "field": "templateInput.site_location", "source": "templateInput", "status": "PASS", - "value": "Bang Sue Engineering Faculty", + "value": "Chiang Mai Service Warehouse", "message": "String is populated" }, { "field": "templateInput.quotation_date_data", "source": "templateInput", "status": "PASS", - "value": "Jun 15, 2026", + "value": "Jun 3, 2026", "message": "String is populated" }, { "field": "templateInput.quotation_code_data", "source": "templateInput", "status": "PASS", - "value": "CRA2606-1108", + "value": "CRA2606-1107", "message": "String is populated" }, { @@ -935,7 +935,7 @@ "field": "templateInput.quotation_price", "source": "templateInput", "status": "PASS", - "value": "THB 3,008,000.00", + "value": "THB 4,418,000.00", "message": "String is populated" }, { @@ -998,35 +998,35 @@ "field": "templateInput.app2", "source": "templateInput", "status": "PASS", - "value": "-", + "value": "Sales Manager UAT", "message": "String is populated" }, { "field": "templateInput.app2_position", "source": "templateInput", "status": "PASS", - "value": "-", + "value": "Sales Manager", "message": "String is populated" }, { "field": "templateInput.app3", "source": "templateInput", "status": "PASS", - "value": "Sales Manager UAT", + "value": "CEO UAT", "message": "String is populated" }, { "field": "templateInput.app3_position", "source": "templateInput", "status": "PASS", - "value": "Sales Manager", + "value": "General Manager", "message": "String is populated" }, { "field": "templateInput.quotation_date", "source": "templateInput", "status": "PASS", - "value": "Jun 15, 2026", + "value": "Jun 3, 2026", "message": "String is populated" }, { @@ -1075,7 +1075,7 @@ "field": "templateInput.quotation_code", "source": "templateInput", "status": "PASS", - "value": "CRA2606-1108", + "value": "CRA2606-1107", "message": "String is populated" }, { @@ -1138,7 +1138,7 @@ "field": "templateInput.quotation_price_data[0][1]", "source": "templateInput", "status": "PASS", - "value": "฿3,008,000.00", + "value": "฿4,418,000.00", "message": "Price table amount cell is formatted" }, { diff --git a/artifacts/pdf-audit/signature-audit-report.json b/artifacts/pdf-audit/signature-audit-report.json index 5843580..c99056b 100644 --- a/artifacts/pdf-audit/signature-audit-report.json +++ b/artifacts/pdf-audit/signature-audit-report.json @@ -1,14 +1,14 @@ { "audit": "approved-snapshot-parity", "overallStatus": "PASS", - "quotationCode": "CRA2606-1108", + "quotationCode": "CRA2606-1107", "approvedSnapshotAvailable": true, "approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", "computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", "inconsistentTemplateKeys": [], - "computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229", - "storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229", - "approvedArtifactReference": null, + "computedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8", + "storedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8", + "approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581", "refreshedFixtureSnapshot": true, "usedAuditFixture": false } diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json index 97b502d..ea1fdf1 100644 --- a/artifacts/pdf-audit/summary.json +++ b/artifacts/pdf-audit/summary.json @@ -1,7 +1,7 @@ { - "generatedAt": "2026-06-30T08:23:40.205Z", + "generatedAt": "2026-06-30T09:16:22.613Z", "overallStatus": "PASS", - "quotationCode": "CRA2606-1108", + "quotationCode": "CRA2606-1107", "artifacts": { "templateVersionReport": "template-version-report.json", "placeholderIntegrityReport": "placeholder-integrity-report.json", diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md index 4b279ee..5352f0c 100644 --- a/artifacts/pdf-audit/summary.md +++ b/artifacts/pdf-audit/summary.md @@ -1,8 +1,8 @@ # PDF Integrity Summary -- Generated At: 2026-06-30T08:23:40.205Z +- Generated At: 2026-06-30T09:16:22.613Z - Overall Status: PASS -- Quotation Code: CRA2606-1108 +- Quotation Code: CRA2606-1107 ## Statuses diff --git a/artifacts/pdf-audit/template-version-report.json b/artifacts/pdf-audit/template-version-report.json index e460b67..0be0b51 100644 --- a/artifacts/pdf-audit/template-version-report.json +++ b/artifacts/pdf-audit/template-version-report.json @@ -2,7 +2,7 @@ "audit": "template-version-integrity", "overallStatus": "PASS", "activeTemplateCount": 1, - "quotationCode": "CRA2606-1108", + "quotationCode": "CRA2606-1107", "approvedTemplateVersionId": null, "approvedTemplateVersionIsActive": false, "issues": [] diff --git a/artifacts/pdf-audit/topic-runtime-report.json b/artifacts/pdf-audit/topic-runtime-report.json index fd65a3d..ca4af66 100644 --- a/artifacts/pdf-audit/topic-runtime-report.json +++ b/artifacts/pdf-audit/topic-runtime-report.json @@ -1,7 +1,7 @@ { "audit": "topic-runtime", "overallStatus": "PASS", - "quotationCode": "CRA2606-1108", + "quotationCode": "CRA2606-1107", "topicCount": 3, "topicInputKeys": [ "topic_1_0", diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json index 6879a8e..bda7c06 100644 --- a/docs/implementation/pdf-audit-report.json +++ b/docs/implementation/pdf-audit-report.json @@ -1,7 +1,7 @@ { - "generatedAt": "2026-06-30T08:23:36.291Z", + "generatedAt": "2026-06-30T09:16:20.352Z", "overallStatus": "PASS", - "quotationCode": "CRA2606-1108", + "quotationCode": "CRA2606-1107", "expectedFixtureCode": "QT-H5-AUDIT", "template": { "templateName": "ALLA Demo Organization Quotation Standard", @@ -263,7 +263,7 @@ "consistency": { "status": "PASS", "approvedSnapshotAvailable": true, - "approvedArtifactReference": null, + "approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581", "inconsistentKeys": [] }, "integritySummary": { diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md index d67b5c3..5125518 100644 --- a/docs/implementation/pdf-audit-report.md +++ b/docs/implementation/pdf-audit-report.md @@ -1,8 +1,8 @@ # PDF Audit Report -- Generated At: 2026-06-30T08:23:36.291Z +- Generated At: 2026-06-30T09:16:20.352Z - Overall Status: PASS -- Quotation Code: CRA2606-1108 +- Quotation Code: CRA2606-1107 - Template: ALLA Demo Organization Quotation Standard v1.0 - Previous Version: None diff --git a/docs/implementation/task-p.7.1-implementation.md b/docs/implementation/task-p.7.1-implementation.md new file mode 100644 index 0000000..e4b39a8 --- /dev/null +++ b/docs/implementation/task-p.7.1-implementation.md @@ -0,0 +1,66 @@ +# Task P.7.1 Implementation Report + +## Scope +This round starts Task P.7.1 by extending the existing notification foundation into a reusable delivery layer for channel-based notifications, with email as the first implemented provider. + +## Review Summary +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/ui-ux-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/ui-ux-rules.md) +- [docs/standards/task-review-checklist.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-review-checklist.md) +- [docs/adr/0008-attachment-storage-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0008-attachment-storage-strategy.md) +- [docs/adr/0009-approved-document-storage-lifecycle.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0009-approved-document-storage-lifecycle.md) +- [docs/implementation/task-f4-notification-framework-foundation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-f4-notification-framework-foundation.md) +- [docs/implementation/task-f5-approval-automation-foundation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-f5-approval-automation-foundation.md) +- [docs/implementation/task-i-storage-artifact-lifecycle.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-i-storage-artifact-lifecycle.md) +- [docs/implementation/task-p.7-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7-implementation.md) + +## Discovery Findings +- The repo already had an in-app notification foundation from Task F4 using `app_notification_events`, `app_notifications`, `app_notification_templates`, and `app_notification_deliveries`. +- Approval automation from Task F5 already publishes reminder/escalation events and proves scheduler-oriented notification orchestration exists, but only for in-app delivery. +- Storage and immutable document delivery foundations already exist and are reusable through: + - [src/features/foundation/document-artifact/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-artifact/server/service.ts) + - [src/features/foundation/document-library/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-library/server/service.ts) + - [src/features/foundation/storage/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/storage/service.ts) +- There was no usable SMTP provider implementation in the workspace yet; `nodemailer` had to be added. +- There was no generic external-delivery history/queue log yet; the old `app_notification_deliveries` rows were still shaped around inbox notifications. + +## Delivered In This Round +- Expanded notification contracts in [src/features/foundation/notifications/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/types.ts) to cover channels, dispatch status, external recipients, template metadata, attachments, and provider interfaces. +- Extended notification template schema and added a new dispatch/history table in: + - [src/db/schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts) + - [drizzle/0009_notification_delivery_foundation.sql](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0009_notification_delivery_foundation.sql) +- Added reusable rendering, queue execution, attachment resolution, and provider plumbing: + - [src/features/foundation/notifications/server/template-renderer.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/template-renderer.ts) + - [src/features/foundation/notifications/server/delivery-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/delivery-engine.ts) + - [src/features/foundation/notifications/server/attachment-service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/attachment-service.ts) + - [src/features/foundation/notifications/server/email-provider.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/email-provider.ts) + - [src/features/foundation/notifications/server/provider-factory.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/provider-factory.ts) + - [src/features/foundation/notifications/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/service.ts) +- Added API entry points for the first reusable send/preview/history/detail/resend flow: + - [src/app/api/notifications/preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/preview/route.ts) + - [src/app/api/notifications/send/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/send/route.ts) + - [src/app/api/notifications/history/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/history/route.ts) + - [src/app/api/notifications/history/[id]/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/history/%5Bid%5D/route.ts) + - [src/app/api/notifications/history/[id]/resend/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/history/%5Bid%5D/resend/route.ts) +- Added new permissions in [src/lib/auth/rbac.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts): + - `notification.send` + - `notification.resend` + - `notification.manage_template` + - `notification.view_history` +- Kept the original approval bell/inbox flow intact by updating [src/features/foundation/notifications/server/event-service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/event-service.ts) only where the richer template contract needed compatibility. + +## Design Notes +- The old in-app notification foundation remains the source of truth for header bell / inbox UX. +- The new delivery foundation uses a separate `app_notification_dispatches` log so external-channel delivery history does not depend on internal inbox rows. +- Attachment resolution is storage-backed only. It reads existing artifact/document-library files and does not regenerate PDFs during send. +- If a channel-specific template is missing, the new service falls back to the event's active in-app template so the API remains usable during migration. + +## Known Gaps After This Round +- No template management UI yet. +- No automated CRM event publication into email dispatch yet; CRM modules still need to call the new service/API explicitly. +- No seed update was added yet for dedicated email templates, so best experience currently comes from template override or the in-app fallback path. +- No Teams / LINE / Slack / SMS / web push provider implementations yet. +- No background worker process yet; queue behavior is modeled in persisted dispatch status plus retry semantics, but still executed synchronously in this round. diff --git a/docs/implementation/task-p.7.1-verification-report.md b/docs/implementation/task-p.7.1-verification-report.md new file mode 100644 index 0000000..cbbb992 --- /dev/null +++ b/docs/implementation/task-p.7.1-verification-report.md @@ -0,0 +1,41 @@ +# Task P.7.1 Verification Report + +## Scope +Verification for the first implementation round of Task P.7.1 notification foundation. + +## Verified Files +- [src/features/foundation/notifications/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/types.ts) +- [src/features/foundation/notifications/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/service.ts) +- [src/features/foundation/notifications/server/email-provider.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/email-provider.ts) +- [src/features/foundation/notifications/server/attachment-service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/attachment-service.ts) +- [src/features/foundation/notifications/server/template-renderer.test.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/template-renderer.test.ts) +- [src/features/foundation/notifications/server/delivery-engine.test.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/delivery-engine.test.ts) +- [src/app/api/notifications/send/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/send/route.ts) +- [src/app/api/notifications/preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/preview/route.ts) +- [src/app/api/notifications/history/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/history/route.ts) + +## Commands Run +```bash +npm install nodemailer@^7.0.7 +npm install -D @types/nodemailer +npm run typecheck +npm exec tsx --test src/features/foundation/notifications/server/template-renderer.test.ts src/features/foundation/notifications/server/delivery-engine.test.ts +npm run build +npm run audit:pdf +``` + +## Results On 2026-06-30 +- `npm run typecheck` = PASS +- `npm exec tsx --test ...` = PASS +- `npm run build` = PASS +- `npm run audit:pdf` = PASS + +## Notes +- `npm run build` completed successfully, but Next.js still emitted the pre-existing Turbopack root warning and existing NFT tracing warning unrelated to this notification task. +- `npm run audit:pdf` refreshed existing audit artifacts and reports under `artifacts/pdf-audit/**` and `docs/implementation/pdf-audit-report.*`. +- End-to-end SMTP send against a real server was not exercised in this round because provider credentials are environment-specific and out of task scope. + +## Residual Risk +- Real email delivery still depends on valid `SMTP_*` environment configuration. +- Channel-specific email templates should be seeded or managed explicitly in a follow-up round for the best operator experience. +- History/detail/resend APIs were compile- and test-verified, but not manually exercised against live dispatch rows in this round. diff --git a/drizzle/0009_notification_delivery_foundation.sql b/drizzle/0009_notification_delivery_foundation.sql new file mode 100644 index 0000000..8131377 --- /dev/null +++ b/drizzle/0009_notification_delivery_foundation.sql @@ -0,0 +1,60 @@ +ALTER TABLE "app_notification_templates" + ADD COLUMN "code" text DEFAULT '' NOT NULL, + ADD COLUMN "name" text DEFAULT '' NOT NULL, + ADD COLUMN "subject_template" text, + ADD COLUMN "body_format" text DEFAULT 'plain' NOT NULL, + ADD COLUMN "language" text DEFAULT 'th' NOT NULL, + ADD COLUMN "variables" jsonb, + ADD COLUMN "version" integer DEFAULT 1 NOT NULL; +--> statement-breakpoint +UPDATE "app_notification_templates" +SET + "code" = CASE + WHEN "code" = '' THEN replace("event_type", '.', '_') || '_' || "channel" + ELSE "code" + END, + "name" = CASE + WHEN "name" = '' THEN "event_type" || ' (' || "channel" || ')' + ELSE "name" + END, + "subject_template" = COALESCE("subject_template", "title_template"), + "variables" = COALESCE("variables", '[]'::jsonb); +--> statement-breakpoint +CREATE TABLE "app_notification_dispatches" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "event_id" text NOT NULL, + "template_id" text, + "entity_type" text NOT NULL, + "entity_id" text NOT NULL, + "channel" text NOT NULL, + "provider_key" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "recipient_to" jsonb NOT NULL, + "recipient_cc" jsonb, + "recipient_bcc" jsonb, + "reply_to" text, + "subject" text NOT NULL, + "body_text" text NOT NULL, + "body_html" text, + "attachments" jsonb, + "recipient_user_id" text, + "recipient_address" text, + "requested_by" text NOT NULL, + "metadata" jsonb, + "last_error" text, + "retry_count" integer DEFAULT 0 NOT NULL, + "max_retry_count" integer DEFAULT 3 NOT NULL, + "last_attempt_at" timestamp with time zone, + "sent_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "app_notification_dispatches_org_event_channel_recipient_idx" + ON "app_notification_dispatches" USING btree ( + "organization_id", + "event_id", + "channel", + "recipient_address" + ); diff --git a/drizzle/0009_stale_next_avengers.sql b/drizzle/0009_stale_next_avengers.sql new file mode 100644 index 0000000..18822d1 --- /dev/null +++ b/drizzle/0009_stale_next_avengers.sql @@ -0,0 +1,89 @@ +CREATE TABLE "app_notification_dispatches" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "event_id" text NOT NULL, + "template_id" text, + "entity_type" text NOT NULL, + "entity_id" text NOT NULL, + "channel" text NOT NULL, + "provider_key" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "recipient_to" jsonb NOT NULL, + "recipient_cc" jsonb, + "recipient_bcc" jsonb, + "reply_to" text, + "subject" text NOT NULL, + "body_text" text NOT NULL, + "body_html" text, + "attachments" jsonb, + "recipient_user_id" text, + "recipient_address" text, + "requested_by" text NOT NULL, + "metadata" jsonb, + "last_error" text, + "retry_count" integer DEFAULT 0 NOT NULL, + "max_retry_count" integer DEFAULT 3 NOT NULL, + "last_attempt_at" timestamp with time zone, + "sent_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "crm_document_libraries" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "code" text NOT NULL, + "name" text NOT NULL, + "document_type" text NOT NULL, + "description" text, + "brand" text NOT NULL, + "language" text NOT NULL, + "product_type" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "crm_document_library_versions" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "library_id" text NOT NULL, + "version" text NOT NULL, + "file_name" text NOT NULL, + "storage_provider" text NOT NULL, + "storage_key" text NOT NULL, + "mime_type" text NOT NULL, + "file_size" integer NOT NULL, + "checksum" text NOT NULL, + "page_count" integer NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "is_active" boolean DEFAULT false NOT NULL, + "published_at" timestamp with time zone, + "published_by" text, + "archived_at" timestamp with time zone, + "archived_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "created_by" text NOT NULL +); +--> statement-breakpoint +DROP INDEX "document_sequences_org_doc_period_branch_idx";--> statement-breakpoint +ALTER TABLE "app_notification_templates" ADD COLUMN "code" text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "app_notification_templates" ADD COLUMN "name" text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "app_notification_templates" ADD COLUMN "subject_template" text;--> statement-breakpoint +ALTER TABLE "app_notification_templates" ADD COLUMN "body_format" text DEFAULT 'plain' NOT NULL;--> statement-breakpoint +ALTER TABLE "app_notification_templates" ADD COLUMN "language" text DEFAULT 'th' NOT NULL;--> statement-breakpoint +ALTER TABLE "app_notification_templates" ADD COLUMN "variables" jsonb;--> statement-breakpoint +ALTER TABLE "app_notification_templates" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "document_sequences" ADD COLUMN "product_type" text DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE "document_sequences" ADD COLUMN "format" text DEFAULT '{prefix}{period}-{running}' NOT NULL;--> statement-breakpoint +ALTER TABLE "document_sequences" ADD COLUMN "reset_policy" text DEFAULT 'period' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "app_notification_dispatches_org_event_channel_recipient_idx" ON "app_notification_dispatches" USING btree ("organization_id","event_id","channel","recipient_address");--> statement-breakpoint +CREATE UNIQUE INDEX "crm_document_libraries_org_code_idx" ON "crm_document_libraries" USING btree ("organization_id","code");--> statement-breakpoint +CREATE UNIQUE INDEX "crm_document_library_versions_library_version_idx" ON "crm_document_library_versions" USING btree ("library_id","version");--> statement-breakpoint +CREATE UNIQUE INDEX "document_sequences_org_branch_product_doc_period_idx" ON "document_sequences" USING btree ("organization_id","branch_id","product_type","document_type","period"); \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..83839c9 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,5757 @@ +{ + "id": "feac04da-e5b6-4e63-926a-15526b1de66f", + "prevId": "61cab7a1-31a8-47e2-b563-e6c9dc313f32", + "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_dispatches": { + "name": "app_notification_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_key": { + "name": "provider_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "recipient_to": { + "name": "recipient_to", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "recipient_cc": { + "name": "recipient_cc", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recipient_bcc": { + "name": "recipient_bcc", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_retry_count": { + "name": "max_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "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": { + "app_notification_dispatches_org_event_channel_recipient_idx": { + "name": "app_notification_dispatches_org_event_channel_recipient_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_template": { + "name": "title_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_format": { + "name": "body_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'plain'" + }, + "link_template": { + "name": "link_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'th'" + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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_libraries": { + "name": "crm_document_libraries", + "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 + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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_libraries_org_code_idx": { + "name": "crm_document_libraries_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_library_versions": { + "name": "crm_document_library_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "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 + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_by": { + "name": "archived_by", + "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 + } + }, + "indexes": { + "crm_document_library_versions_library_version_idx": { + "name": "crm_document_library_versions_library_version_idx", + "columns": [ + { + "expression": "library_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_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": "''" + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "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 + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{prefix}{period}-{running}'" + }, + "reset_policy": { + "name": "reset_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'period'" + }, + "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_branch_product_doc_period_idx": { + "name": "document_sequences_org_branch_product_doc_period_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "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 bdd14c8..81902e4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1782913600000, "tag": "0008_document_sequence_scope_refactor", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1782815675836, + "tag": "0009_stale_next_avengers", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/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-lock.json b/package-lock.json index 8d5756b..988f65b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", "nextjs-toploader": "^3.9.17", + "nodemailer": "^7.0.13", "nuqs": "^2.8.9", "postcss": "8.5.14", "postgres": "^3.4.7", @@ -93,6 +94,7 @@ "devDependencies": { "@faker-js/faker": "^9.9.0", "@types/node": "22.10.2", + "@types/nodemailer": "^8.0.1", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/sort-by": "^1.2.3", @@ -6728,6 +6730,16 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -9424,6 +9436,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", diff --git a/package.json b/package.json index fc0e837..dc0753d 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", "nextjs-toploader": "^3.9.17", + "nodemailer": "^7.0.13", "nuqs": "^2.8.9", "postcss": "8.5.14", "postgres": "^3.4.7", @@ -136,6 +137,7 @@ "devDependencies": { "@faker-js/faker": "^9.9.0", "@types/node": "22.10.2", + "@types/nodemailer": "^8.0.1", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/sort-by": "^1.2.3", diff --git a/plans/task-p.7.1.md b/plans/task-p.7.1.md new file mode 100644 index 0000000..df295cd --- /dev/null +++ b/plans/task-p.7.1.md @@ -0,0 +1,329 @@ +# Task P.7.1 - Notification Foundation + +## Objective +Build a generic Notification Foundation for ALLA OS that supports event-driven notifications across multiple delivery channels. + +The first implementation target is `Email`, but the architecture must support future channels such as: +- Email +- Microsoft Teams +- LINE OA +- Slack +- Mobile Push +- SMS +- Web Notification + +The Notification Foundation must not be coupled to CRM quotation only. + +## Background +Completed phases: +- P.4 PDF Runtime +- P.5 Template Management +- P.5.1 Production Hardening +- P.6 Document Library +- P.6.1 Document Sequence Refactor +- P.7 Document Assembly Framework + +Current capability: + +```text +Quotation -> Approved PDF -> Document Assembly -> Final Customer PDF +``` + +Next capability: + +```text +Business Event -> Notification Event -> Notification Engine -> Email / Teams / LINE / Slack +``` + +## Discovery Requirement +Before implementation, inspect the existing project and review at minimum: +- AGENTS.md +- authentication layer / RBAC +- audit logging +- storage abstraction +- document assembly +- document library +- CRM approval flow +- existing email utilities, if any +- existing queue / background job infrastructure +- existing scheduler / automation support + +Do not assume notification infrastructure already exists. Produce a discovery summary before implementation. + +## Design Principles +The notification engine must be: +- event-driven +- channel-agnostic +- template-driven +- retryable +- auditable +- extensible + +Do not hardcode quotation logic inside the notification engine. + +## Core Architecture +Design around: + +```text +Business Event + -> Notification Event + -> Notification Dispatcher + -> Email + -> Teams + -> LINE OA + -> Slack + -> Future Providers +``` + +Business modules only publish events. They must not directly call SMTP or a provider SDK. + +## Notification Events +Support generic events. Initial CRM events: +- `quotation.created` +- `quotation.submitted` +- `quotation.approved` +- `quotation.rejected` +- `quotation.sent` +- `quotation.cancelled` +- `lead.assigned` +- `opportunity.assigned` + +Future events: +- `po.received` +- `service.completed` +- `maintenance.due` +- `inventory.low` +- `asset.expired` + +Events should carry payload only. + +## Notification Template +Support reusable notification templates with fields: +- `code` +- `name` +- `channel` +- `subject` +- `body` +- `language` +- `variables` +- `active` +- `version` + +Support placeholder replacement. + +Example: + +```text +Dear {{customerName}} +Your quotation {{quotationCode}} has been approved. + +Attached: +- Quotation +- SLA + +Thank you. +``` + +## Notification Channels +Create a provider abstraction: + +```ts +NotificationProvider +``` + +Methods: +- `send()` +- `validate()` +- `healthCheck()` + +Implement `EmailProvider` only in this phase. Other providers remain placeholders. + +## Email Foundation +Support: +- SMTP +- provider abstraction +- HTML body +- plain text fallback +- multiple recipients +- CC +- BCC +- Reply-To +- attachments + +No provider lock-in. + +## Attachment Support +Notification must support attachments such as: +- approved PDF +- final assembled PDF +- SLA +- warranty + +Attachments must come from the storage abstraction. Do not regenerate PDFs during sending. + +## Notification Queue +Create a notification queue abstraction. Even if processed synchronously now, the architecture must allow future background processing. + +States: +- `pending` +- `sending` +- `sent` +- `failed` +- `retry` + +## Notification Log +Create notification history that tracks: +- event +- recipient +- channel +- template +- subject +- attachment list +- status +- error +- retry count +- createdAt +- sentAt + +This becomes permanent delivery audit history. + +## Document Package Integration +Integrate P.7 output. + +```text +Quotation -> Approved PDF -> Assembly -> Document Package -> Notification +``` + +Notification must never rebuild PDFs. It must use stored artifacts only. + +## API Requirements +Create APIs for: +- send notification +- resend notification +- preview notification +- list notifications +- notification history +- notification detail + +Notification sending should support: +- recipient override +- template override +- attachment selection +- dry-run preview + +## CRM Integration +Do not automatically send emails yet. Instead provide service methods that CRM modules can call. + +Example: + +```text +Quotation Approved -> Notification Service -> Email Provider +``` + +Future phases decide when automatic sending occurs. + +## RBAC +Add permissions: +- `notificationRead` +- `notificationSend` +- `notificationResend` +- `notificationManageTemplate` +- `notificationViewHistory` + +## Audit Logging +Record: +- notification requested +- provider selected +- send started +- send succeeded +- send failed +- resend +- template changed + +## Validation +Validate: +- recipient exists +- valid email +- template exists +- attachment exists +- storage object exists +- provider available + +Return business-readable errors only. + +## Testing Requirements +Add tests for: +- template rendering +- placeholder replacement +- attachment resolution +- provider abstraction +- notification queue +- failed send +- retry +- audit logging + +Run: + +```bash +npm run typecheck +npm run build +npm run audit:pdf +``` + +## Acceptance Criteria +- Generic Notification Engine exists. +- Business modules publish notification events instead of calling providers directly. +- Email provider abstraction is implemented. +- Notification templates support placeholders. +- Attachments resolve from stored artifacts. +- Notification history is persisted. +- Retry architecture exists. +- Queue abstraction exists. +- Notification service is reusable across ALLA OS. +- No CRM module is tightly coupled to SMTP or a provider SDK. + +## Out of Scope + +### P.7.2 +- SMTP provider configuration UI +- provider secrets management +- SendGrid / Microsoft Graph / Resend implementation +- email branding +- DKIM / SPF guidance + +### P.7.3 +- automatic approval notifications +- automatic quotation emails +- customer email workflow +- manager notifications + +### P.8 +- user notification preferences +- subscription management +- channel selection UI +- notification scheduling + +## Final Success Condition +Task P.7.1 is complete when ALLA OS has a reusable, event-driven Notification Foundation capable of rendering templates, resolving stored document artifacts, dispatching notifications through a provider abstraction (initially Email), persisting delivery history, and serving as the foundation for future notification channels without coupling business modules to specific delivery mechanisms. + +--- + +## Implementation Status - 2026-06-30 + +Current round completed: +- reviewed existing notification, approval automation, storage, document artifact, and document assembly foundations before implementation +- extended notification schema/contracts for generic dispatch history, richer templates, provider abstraction, and retry state +- added SMTP-based `EmailNotificationProvider` +- added reusable preview/send/resend/history/detail APIs under `/api/notifications/**` +- added attachment resolution from stored artifacts and document-library files only +- added tests for template rendering and retry queue execution +- verified `npm run typecheck`, targeted notification tests, `npm run build`, and `npm run audit:pdf` + +Implementation notes: +- [docs/implementation/task-p.7.1-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7.1-implementation.md) +- [docs/implementation/task-p.7.1-verification-report.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7.1-verification-report.md) + +Open follow-ups: +- add dedicated seeded email templates for all initial CRM events instead of relying on fallback behavior +- add template management UI and provider configuration UX +- move dispatch processing to a background worker when scheduler / worker runtime is introduced diff --git a/src/app/api/notifications/history/[id]/resend/route.ts b/src/app/api/notifications/history/[id]/resend/route.ts new file mode 100644 index 0000000..a3ec209 --- /dev/null +++ b/src/app/api/notifications/history/[id]/resend/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { resendNotification } from '@/features/foundation/notifications/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function POST( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationResend, + }); + const { id } = await context.params; + const dispatch = await resendNotification({ + organizationId: organization.id, + actorUserId: session.user.id, + dispatchId: id, + }); + + return NextResponse.json({ + message: 'Notification resent successfully', + dispatch, + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to resend notification' }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/history/[id]/route.ts b/src/app/api/notifications/history/[id]/route.ts new file mode 100644 index 0000000..55d78f6 --- /dev/null +++ b/src/app/api/notifications/history/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { getNotificationDispatchDetail } from '@/features/foundation/notifications/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationViewHistory, + }); + const { id } = await context.params; + const result = await getNotificationDispatchDetail(organization.id, id); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load notification detail' }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/history/route.ts b/src/app/api/notifications/history/route.ts new file mode 100644 index 0000000..5b8c8c9 --- /dev/null +++ b/src/app/api/notifications/history/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { listNotificationHistory } from '@/features/foundation/notifications/server/service'; +import { NOTIFICATION_CHANNELS, NOTIFICATION_DISPATCH_STATUSES } from '@/features/foundation/notifications/types'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET(request: NextRequest) { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationViewHistory, + }); + const { searchParams } = request.nextUrl; + + const page = Number(searchParams.get('page') ?? 1); + const limit = Number(searchParams.get('limit') ?? 20); + const channel = searchParams.get('channel'); + const status = searchParams.get('status'); + + const result = await listNotificationHistory(organization.id, { + page, + limit, + channel: + channel && [...NOTIFICATION_CHANNELS, 'all'].includes(channel as never) + ? (channel as (typeof NOTIFICATION_CHANNELS)[number] | 'all') + : 'all', + status: + status && [...NOTIFICATION_DISPATCH_STATUSES, 'all'].includes(status as never) + ? (status as (typeof NOTIFICATION_DISPATCH_STATUSES)[number] | 'all') + : 'all', + eventType: searchParams.get('eventType') ?? undefined, + entityType: searchParams.get('entityType') ?? undefined, + entityId: searchParams.get('entityId') ?? undefined, + search: searchParams.get('search') ?? undefined, + }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load notification history' }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/preview/route.ts b/src/app/api/notifications/preview/route.ts new file mode 100644 index 0000000..0b1271f --- /dev/null +++ b/src/app/api/notifications/preview/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + NOTIFICATION_BODY_FORMATS, + NOTIFICATION_CHANNELS, +} from '@/features/foundation/notifications/types'; +import { previewNotification } from '@/features/foundation/notifications/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const recipientSchema = z.object({ + email: z.string().email(), + name: z.string().trim().min(1).optional().nullable(), +}); + +const attachmentSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('artifact'), + artifactId: z.string().min(1), + label: z.string().optional().nullable(), + }), + z.object({ + type: z.literal('document_library_version'), + versionId: z.string().min(1), + label: z.string().optional().nullable(), + }), + z.object({ + type: z.literal('active_document_library'), + libraryId: z.string().min(1), + label: z.string().optional().nullable(), + }), +]); + +const previewSchema = z.object({ + eventType: z.string().min(1), + entityType: z.string().min(1), + entityId: z.string().min(1), + channel: z.enum(NOTIFICATION_CHANNELS), + payload: z.record(z.string(), z.unknown()).optional(), + templateOverride: z + .object({ + subjectTemplate: z.string().optional(), + titleTemplate: z.string().optional(), + bodyTemplate: z.string().optional(), + bodyFormat: z.enum(NOTIFICATION_BODY_FORMATS).optional(), + linkTemplate: z.string().nullable().optional(), + }) + .optional(), + recipientOverride: z.object({ + to: z.array(recipientSchema).min(1), + cc: z.array(recipientSchema).optional(), + bcc: z.array(recipientSchema).optional(), + replyTo: z.string().email().nullable().optional(), + recipientUserId: z.string().nullable().optional(), + }), + attachments: z.array(attachmentSchema).optional(), + providerKey: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationSend, + }); + const parsed = previewSchema.parse(await request.json()); + + const result = await previewNotification({ + organizationId: organization.id, + actorUserId: session.user.id, + ...parsed, + }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: 'Invalid preview payload', issues: error.flatten() }, + { status: 400 }, + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to preview notification' }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/send/route.ts b/src/app/api/notifications/send/route.ts new file mode 100644 index 0000000..72022f0 --- /dev/null +++ b/src/app/api/notifications/send/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + NOTIFICATION_BODY_FORMATS, + NOTIFICATION_CHANNELS, +} from '@/features/foundation/notifications/types'; +import { sendNotification } from '@/features/foundation/notifications/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const recipientSchema = z.object({ + email: z.string().email(), + name: z.string().trim().min(1).optional().nullable(), +}); + +const attachmentSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('artifact'), + artifactId: z.string().min(1), + label: z.string().optional().nullable(), + }), + z.object({ + type: z.literal('document_library_version'), + versionId: z.string().min(1), + label: z.string().optional().nullable(), + }), + z.object({ + type: z.literal('active_document_library'), + libraryId: z.string().min(1), + label: z.string().optional().nullable(), + }), +]); + +const sendSchema = z.object({ + eventType: z.string().min(1), + entityType: z.string().min(1), + entityId: z.string().min(1), + channel: z.enum(NOTIFICATION_CHANNELS), + payload: z.record(z.string(), z.unknown()).optional(), + templateOverride: z + .object({ + subjectTemplate: z.string().optional(), + titleTemplate: z.string().optional(), + bodyTemplate: z.string().optional(), + bodyFormat: z.enum(NOTIFICATION_BODY_FORMATS).optional(), + linkTemplate: z.string().nullable().optional(), + }) + .optional(), + recipientOverride: z.object({ + to: z.array(recipientSchema).min(1), + cc: z.array(recipientSchema).optional(), + bcc: z.array(recipientSchema).optional(), + replyTo: z.string().email().nullable().optional(), + recipientUserId: z.string().nullable().optional(), + }), + attachments: z.array(attachmentSchema).optional(), + providerKey: z.string().optional(), + maxRetryCount: z.number().int().min(1).max(10).optional(), +}); + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.notificationSend, + }); + const parsed = sendSchema.parse(await request.json()); + + const result = await sendNotification({ + organizationId: organization.id, + actorUserId: session.user.id, + ...parsed, + }); + + return NextResponse.json({ + message: 'Notification sent successfully', + dispatch: result, + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: 'Invalid send payload', issues: error.flatten() }, + { status: 400 }, + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to send notification' }, { status: 500 }); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index d501890..89ce574 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -255,10 +255,17 @@ export const appNotificationTemplates = pgTable( id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), eventType: text('event_type').notNull(), + code: text('code').default('').notNull(), + name: text('name').default('').notNull(), channel: text('channel').notNull(), + subjectTemplate: text('subject_template'), titleTemplate: text('title_template').notNull(), bodyTemplate: text('body_template').notNull(), + bodyFormat: text('body_format').default('plain').notNull(), linkTemplate: text('link_template'), + language: text('language').default('th').notNull(), + variables: jsonb('variables'), + version: integer('version').default(1).notNull(), isActive: boolean('is_active').default(true).notNull(), metadata: jsonb('metadata'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), @@ -276,6 +283,45 @@ export const appNotificationTemplates = pgTable( }) ); +export const appNotificationDispatches = pgTable( + 'app_notification_dispatches', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + eventId: text('event_id').notNull(), + templateId: text('template_id'), + entityType: text('entity_type').notNull(), + entityId: text('entity_id').notNull(), + channel: text('channel').notNull(), + providerKey: text('provider_key').notNull(), + status: text('status').default('pending').notNull(), + recipientTo: jsonb('recipient_to').notNull(), + recipientCc: jsonb('recipient_cc'), + recipientBcc: jsonb('recipient_bcc'), + replyTo: text('reply_to'), + subject: text('subject').notNull(), + bodyText: text('body_text').notNull(), + bodyHtml: text('body_html'), + attachments: jsonb('attachments'), + recipientUserId: text('recipient_user_id'), + recipientAddress: text('recipient_address'), + requestedBy: text('requested_by').notNull(), + metadata: jsonb('metadata'), + lastError: text('last_error'), + retryCount: integer('retry_count').default(0).notNull(), + maxRetryCount: integer('max_retry_count').default(3).notNull(), + lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }), + sentAt: timestamp('sent_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + organizationEventChannelRecipientIdx: uniqueIndex( + 'app_notification_dispatches_org_event_channel_recipient_idx' + ).on(table.organizationId, table.eventId, table.channel, table.recipientAddress) + }) +); + export const appNotificationDeliveries = pgTable('app_notification_deliveries', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), diff --git a/src/features/foundation/notifications/server/attachment-service.ts b/src/features/foundation/notifications/server/attachment-service.ts new file mode 100644 index 0000000..44675f5 --- /dev/null +++ b/src/features/foundation/notifications/server/attachment-service.ts @@ -0,0 +1,83 @@ +import 'server-only'; + +import { + downloadArtifact, +} from '@/features/foundation/document-artifact/server/service'; +import { + downloadActiveDocumentLibraryVersion, + downloadDocumentLibraryVersion, +} from '@/features/foundation/document-library/server/service'; +import type { + NotificationAttachmentRef, + ResolvedNotificationAttachment, +} from '../types'; + +async function streamToBuffer(stream: ReadableStream) { + return Buffer.from(await new Response(stream).arrayBuffer()); +} + +export async function resolveNotificationAttachments(input: { + organizationId: string; + userId: string; + attachments: NotificationAttachmentRef[]; +}): Promise { + return Promise.all( + input.attachments.map(async (attachment) => { + if (attachment.type === 'artifact') { + const result = await downloadArtifact({ + artifactId: attachment.artifactId, + organizationId: input.organizationId, + userId: input.userId, + auditDownload: false, + }); + + return { + sourceType: 'artifact' as const, + sourceId: attachment.artifactId, + fileName: result.object.fileName ?? result.artifact.fileName, + contentType: result.object.contentType, + size: result.object.size, + checksum: result.object.checksum ?? null, + label: attachment.label ?? null, + buffer: await streamToBuffer(result.object.body), + }; + } + + if (attachment.type === 'document_library_version') { + const result = await downloadDocumentLibraryVersion({ + versionId: attachment.versionId, + organizationId: input.organizationId, + userId: input.userId, + }); + + return { + sourceType: 'document_library_version' as const, + sourceId: attachment.versionId, + fileName: result.object.fileName, + contentType: result.object.contentType, + size: result.object.size, + checksum: result.object.checksum ?? null, + label: attachment.label ?? null, + buffer: await streamToBuffer(result.object.body), + }; + } + + const result = await downloadActiveDocumentLibraryVersion({ + libraryId: attachment.libraryId, + organizationId: input.organizationId, + userId: input.userId, + }); + + return { + sourceType: 'active_document_library' as const, + sourceId: attachment.libraryId, + fileName: result.object.fileName, + contentType: result.object.contentType, + size: result.object.size, + checksum: result.object.checksum ?? null, + label: attachment.label ?? null, + buffer: await streamToBuffer(result.object.body), + }; + }), + ); +} diff --git a/src/features/foundation/notifications/server/delivery-engine.test.ts b/src/features/foundation/notifications/server/delivery-engine.test.ts new file mode 100644 index 0000000..3e32411 --- /dev/null +++ b/src/features/foundation/notifications/server/delivery-engine.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { executeNotificationDispatch } from './delivery-engine'; +import type { NotificationProvider } from '../types'; + +function createProvider(options: { + validateError?: string; + sendError?: string; +}): NotificationProvider { + return { + channel: 'email', + key: 'smtp', + async validate() { + if (options.validateError) { + throw new Error(options.validateError); + } + }, + async healthCheck() { + return { ok: true }; + }, + async send() { + if (options.sendError) { + throw new Error(options.sendError); + } + + return { + providerMessageId: 'msg-1', + acceptedRecipients: ['to@example.com'], + rejectedRecipients: [], + }; + }, + }; +} + +function createMessage() { + return { + dispatchId: 'dispatch-1', + subject: 'Test', + bodyText: 'Plain', + bodyHtml: null, + to: [{ email: 'to@example.com', name: null }], + cc: [], + bcc: [], + replyTo: null, + attachments: [], + attachmentBuffers: [], + }; +} + +test('executeNotificationDispatch marks success when provider sends', async () => { + const result = await executeNotificationDispatch({ + provider: createProvider({}), + message: createMessage(), + retryCount: 0, + maxRetryCount: 3, + }); + + assert.equal(result.status, 'sent'); + assert.equal(result.retryCount, 1); + assert.equal(result.errorMessage, null); + assert.equal(result.providerResult?.providerMessageId, 'msg-1'); + assert.ok(result.sentAt instanceof Date); +}); + +test('executeNotificationDispatch schedules retry before max retry count', async () => { + const result = await executeNotificationDispatch({ + provider: createProvider({ sendError: 'SMTP offline' }), + message: createMessage(), + retryCount: 0, + maxRetryCount: 3, + }); + + assert.equal(result.status, 'retry'); + assert.equal(result.retryCount, 1); + assert.equal(result.errorMessage, 'SMTP offline'); + assert.equal(result.sentAt, null); +}); + +test('executeNotificationDispatch fails permanently at retry limit', async () => { + const result = await executeNotificationDispatch({ + provider: createProvider({ validateError: 'SMTP host is not configured.' }), + message: createMessage(), + retryCount: 2, + maxRetryCount: 3, + }); + + assert.equal(result.status, 'failed'); + assert.equal(result.retryCount, 3); + assert.equal(result.errorMessage, 'SMTP host is not configured.'); +}); diff --git a/src/features/foundation/notifications/server/delivery-engine.ts b/src/features/foundation/notifications/server/delivery-engine.ts new file mode 100644 index 0000000..fa48229 --- /dev/null +++ b/src/features/foundation/notifications/server/delivery-engine.ts @@ -0,0 +1,62 @@ +import type { + NotificationDispatchStatus, + NotificationProvider, + NotificationProviderSendInput, + NotificationProviderSendResult, +} from '../types'; + +export type NotificationDispatchExecutionInput = { + provider: NotificationProvider; + message: NotificationProviderSendInput; + retryCount: number; + maxRetryCount: number; +}; + +export type NotificationDispatchExecutionResult = { + status: NotificationDispatchStatus; + retryCount: number; + sentAt: Date | null; + providerResult: NotificationProviderSendResult | null; + errorMessage: string | null; + attemptedAt: Date; +}; + +function normalizeProviderError(error: unknown) { + if (error instanceof Error) { + return error.message.slice(0, 500); + } + + return 'Unknown notification delivery error'; +} + +export async function executeNotificationDispatch( + input: NotificationDispatchExecutionInput, +): Promise { + const attemptedAt = new Date(); + + try { + await input.provider.validate(); + const providerResult = await input.provider.send(input.message); + + return { + status: 'sent', + retryCount: input.retryCount + 1, + sentAt: attemptedAt, + providerResult, + errorMessage: null, + attemptedAt, + }; + } catch (error) { + const retryCount = input.retryCount + 1; + const shouldRetry = retryCount < input.maxRetryCount; + + return { + status: shouldRetry ? 'retry' : 'failed', + retryCount, + sentAt: null, + providerResult: null, + errorMessage: normalizeProviderError(error), + attemptedAt, + }; + } +} diff --git a/src/features/foundation/notifications/server/email-provider.ts b/src/features/foundation/notifications/server/email-provider.ts new file mode 100644 index 0000000..965e1b9 --- /dev/null +++ b/src/features/foundation/notifications/server/email-provider.ts @@ -0,0 +1,134 @@ +import 'server-only'; + +import type { Transporter } from 'nodemailer'; +import type SMTPTransport from 'nodemailer/lib/smtp-transport'; +import type { + NotificationChannel, + NotificationProvider, + NotificationProviderSendInput, + NotificationProviderSendResult, +} from '../types'; + +type SmtpConfig = { + host: string; + port: number; + secure: boolean; + user?: string; + pass?: string; + from: string; +}; + +function parseBoolean(value: string | undefined, fallback: boolean) { + if (!value) { + return fallback; + } + + return value === 'true' || value === '1'; +} + +function getSmtpConfig(): SmtpConfig { + const host = process.env.SMTP_HOST?.trim() ?? ''; + const port = Number(process.env.SMTP_PORT ?? '587'); + const secure = parseBoolean(process.env.SMTP_SECURE, port === 465); + const user = process.env.SMTP_USER?.trim(); + const pass = process.env.SMTP_PASS?.trim(); + const from = process.env.SMTP_FROM?.trim() ?? ''; + + return { host, port, secure, user, pass, from }; +} + +async function createTransporter() { + const nodemailer = await import('nodemailer'); + const config = getSmtpConfig(); + + return nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.user ? { user: config.user, pass: config.pass } : undefined, + } satisfies SMTPTransport.Options); +} + +function formatRecipients( + recipients: NotificationProviderSendInput['to'], +): string[] { + return recipients.map((recipient) => + recipient.name?.trim() + ? `"${recipient.name.trim()}" <${recipient.email}>` + : recipient.email, + ); +} + +export class EmailNotificationProvider implements NotificationProvider { + readonly channel: NotificationChannel = 'email'; + readonly key = 'smtp'; + + async validate() { + const config = getSmtpConfig(); + + if (!config.host) { + throw new Error('SMTP host is not configured.'); + } + + if (!Number.isFinite(config.port) || config.port <= 0) { + throw new Error('SMTP port is invalid.'); + } + + if (!config.from) { + throw new Error('SMTP from address is not configured.'); + } + } + + async healthCheck() { + try { + await this.validate(); + const transporter = await createTransporter(); + await transporter.verify(); + + return { ok: true as const }; + } catch (error) { + return { + ok: false as const, + detail: error instanceof Error ? error.message : 'Unknown SMTP health check failure', + }; + } + } + + async send( + input: NotificationProviderSendInput, + ): Promise { + await this.validate(); + const transporter = (await createTransporter()) as Transporter; + const config = getSmtpConfig(); + + const info = await transporter.sendMail({ + from: config.from, + to: formatRecipients(input.to), + cc: input.cc.length ? formatRecipients(input.cc) : undefined, + bcc: input.bcc.length ? formatRecipients(input.bcc) : undefined, + replyTo: input.replyTo ?? undefined, + subject: input.subject, + text: input.bodyText, + html: input.bodyHtml ?? undefined, + attachments: input.attachments.map((attachment, index) => ({ + filename: attachment.fileName, + content: input.attachmentBuffers[index], + contentType: attachment.contentType, + })), + }); + + return { + providerMessageId: info.messageId ?? null, + acceptedRecipients: Array.isArray(info.accepted) + ? info.accepted.map((value: string | { address: string }) => String(value)) + : [], + rejectedRecipients: Array.isArray(info.rejected) + ? info.rejected.map((value: string | { address: string }) => String(value)) + : [], + metadata: { + envelope: info.envelope, + response: info.response ?? null, + }, + }; + } +} diff --git a/src/features/foundation/notifications/server/event-service.ts b/src/features/foundation/notifications/server/event-service.ts index a9badf3..1c8949c 100644 --- a/src/features/foundation/notifications/server/event-service.ts +++ b/src/features/foundation/notifications/server/event-service.ts @@ -102,10 +102,19 @@ function mapTemplateRecord( id: row.id, organizationId: row.organizationId, eventType: row.eventType, + code: row.code, + name: row.name, channel: row.channel as NotificationTemplateRecord['channel'], + subjectTemplate: row.subjectTemplate ?? null, titleTemplate: row.titleTemplate, bodyTemplate: row.bodyTemplate, + bodyFormat: row.bodyFormat as NotificationTemplateRecord['bodyFormat'], linkTemplate: row.linkTemplate ?? null, + language: row.language, + variables: Array.isArray(row.variables) + ? row.variables.filter((value): value is string => typeof value === 'string') + : [], + version: row.version, isActive: row.isActive, metadata: (row.metadata as Record | null) ?? null, createdAt: row.createdAt.toISOString(), @@ -239,7 +248,7 @@ export async function processNotificationEvent(eventId: string) { recipientUserId, eventId: event.id, title: rendered.title, - body: rendered.body, + body: rendered.bodyText, linkUrl: rendered.linkUrl, severity: rule.severity, status: 'unread', diff --git a/src/features/foundation/notifications/server/provider-factory.ts b/src/features/foundation/notifications/server/provider-factory.ts new file mode 100644 index 0000000..9657b1f --- /dev/null +++ b/src/features/foundation/notifications/server/provider-factory.ts @@ -0,0 +1,21 @@ +import 'server-only'; + +import { EmailNotificationProvider } from './email-provider'; +import type { NotificationChannel, NotificationProvider } from '../types'; + +export function getNotificationProvider( + channel: NotificationChannel, + providerKey?: string, +): NotificationProvider { + if (channel === 'email') { + const provider = new EmailNotificationProvider(); + + if (providerKey && provider.key !== providerKey) { + throw new Error(`Unsupported email provider: ${providerKey}`); + } + + return provider; + } + + throw new Error(`Notification provider for channel ${channel} is not implemented yet.`); +} diff --git a/src/features/foundation/notifications/server/service.ts b/src/features/foundation/notifications/server/service.ts new file mode 100644 index 0000000..29ef9e3 --- /dev/null +++ b/src/features/foundation/notifications/server/service.ts @@ -0,0 +1,733 @@ +import 'server-only'; + +import { and, count, desc, eq, ilike, isNull, or } from 'drizzle-orm'; +import { + appNotificationDispatches, + appNotificationEvents, + appNotificationTemplates, +} from '@/db/schema'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import { resolveNotificationAttachments } from './attachment-service'; +import { executeNotificationDispatch } from './delivery-engine'; +import { getNotificationProvider } from './provider-factory'; +import { renderNotificationTemplate } from './template-renderer'; +import type { + NotificationDispatchRecord, + NotificationDispatchRecipient, + NotificationEventRecord, + NotificationHistoryFilters, + NotificationPreviewRequest, + NotificationRenderResult, + NotificationSendRequest, + NotificationTemplateRecord, + ResolvedNotificationAttachment, +} from '../types'; + +type TemplateOverride = NotificationPreviewRequest['templateOverride']; + +function normalizeDispatchRecipients(value: unknown): NotificationDispatchRecipient[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((item) => { + if (!item || typeof item !== 'object') { + return []; + } + + const email = typeof (item as { email?: unknown }).email === 'string' + ? (item as { email: string }).email.trim().toLowerCase() + : ''; + + if (!email) { + return []; + } + + const name = typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name.trim() + : null; + + return [{ email, name }]; + }); +} + +function normalizeAttachments( + value: unknown, +): Array> { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((item) => { + if (!item || typeof item !== 'object') { + return []; + } + + const candidate = item as Record; + + if ( + typeof candidate.sourceType !== 'string' || + typeof candidate.sourceId !== 'string' || + typeof candidate.fileName !== 'string' || + typeof candidate.contentType !== 'string' || + typeof candidate.size !== 'number' + ) { + return []; + } + + return [ + { + sourceType: candidate.sourceType as ResolvedNotificationAttachment['sourceType'], + sourceId: candidate.sourceId, + fileName: candidate.fileName, + contentType: candidate.contentType, + size: candidate.size, + checksum: + typeof candidate.checksum === 'string' ? candidate.checksum : null, + label: typeof candidate.label === 'string' ? candidate.label : null, + }, + ]; + }); +} + +function mapEventRecord(row: typeof appNotificationEvents.$inferSelect): NotificationEventRecord { + return { + id: row.id, + organizationId: row.organizationId, + eventType: row.eventType, + entityType: row.entityType, + entityId: row.entityId, + actorUserId: row.actorUserId ?? null, + payload: (row.payload as Record | null) ?? null, + dedupeKey: row.dedupeKey ?? null, + status: row.status as NotificationEventRecord['status'], + lastError: row.lastError ?? null, + publishedAt: row.publishedAt.toISOString(), + processedAt: row.processedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function mapTemplateRecord( + row: typeof appNotificationTemplates.$inferSelect, +): NotificationTemplateRecord { + return { + id: row.id, + organizationId: row.organizationId, + eventType: row.eventType, + code: row.code, + name: row.name, + channel: row.channel as NotificationTemplateRecord['channel'], + subjectTemplate: row.subjectTemplate ?? null, + titleTemplate: row.titleTemplate, + bodyTemplate: row.bodyTemplate, + bodyFormat: row.bodyFormat as NotificationTemplateRecord['bodyFormat'], + linkTemplate: row.linkTemplate ?? null, + language: row.language, + variables: Array.isArray(row.variables) + ? row.variables.filter((value): value is string => typeof value === 'string') + : [], + version: row.version, + isActive: row.isActive, + metadata: (row.metadata as Record | null) ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + createdBy: row.createdBy, + updatedBy: row.updatedBy, + }; +} + +function mapDispatchRecord( + row: typeof appNotificationDispatches.$inferSelect, +): NotificationDispatchRecord { + return { + id: row.id, + organizationId: row.organizationId, + eventId: row.eventId, + templateId: row.templateId ?? null, + entityType: row.entityType, + entityId: row.entityId, + channel: row.channel as NotificationDispatchRecord['channel'], + providerKey: row.providerKey, + status: row.status as NotificationDispatchRecord['status'], + recipientTo: normalizeDispatchRecipients(row.recipientTo), + recipientCc: normalizeDispatchRecipients(row.recipientCc), + recipientBcc: normalizeDispatchRecipients(row.recipientBcc), + replyTo: row.replyTo ?? null, + subject: row.subject, + bodyText: row.bodyText, + bodyHtml: row.bodyHtml ?? null, + attachments: normalizeAttachments(row.attachments), + recipientUserId: row.recipientUserId ?? null, + recipientAddress: row.recipientAddress ?? null, + requestedBy: row.requestedBy, + metadata: (row.metadata as Record | null) ?? null, + lastError: row.lastError ?? null, + retryCount: row.retryCount, + maxRetryCount: row.maxRetryCount, + lastAttemptAt: row.lastAttemptAt?.toISOString() ?? null, + sentAt: row.sentAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function normalizeEmailAddress(value: string) { + return value.trim().toLowerCase(); +} + +function assertRecipients(recipients: NotificationDispatchRecipient[], label: string) { + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!recipients.length) { + if (label === 'to') { + throw new AuthError('At least one recipient is required.', 400); + } + + return; + } + + for (const recipient of recipients) { + if (!emailPattern.test(recipient.email)) { + throw new AuthError(`Invalid ${label} email: ${recipient.email}`, 400); + } + } +} + +async function loadTemplate( + organizationId: string, + eventType: string, + channel: NotificationPreviewRequest['channel'], +) { + const rows = await db + .select() + .from(appNotificationTemplates) + .where( + and( + eq(appNotificationTemplates.organizationId, organizationId), + eq(appNotificationTemplates.eventType, eventType), + eq(appNotificationTemplates.isActive, true), + isNull(appNotificationTemplates.deletedAt), + ), + ) + .limit(10); + + const row = + rows.find((candidate) => candidate.channel === channel) ?? + rows.find((candidate) => candidate.channel === 'in_app') ?? + rows[0]; + + return row ? mapTemplateRecord(row) : null; +} + +async function ensureEvent(input: { + organizationId: string; + eventType: string; + entityType: string; + entityId: string; + actorUserId: string; + payload: Record; +}) { + const [created] = await db + .insert(appNotificationEvents) + .values({ + id: crypto.randomUUID(), + organizationId: input.organizationId, + eventType: input.eventType, + entityType: input.entityType, + entityId: input.entityId, + actorUserId: input.actorUserId, + payload: input.payload, + status: 'pending', + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + await auditAction({ + organizationId: input.organizationId, + userId: input.actorUserId, + entityType: 'app_notification_event', + entityId: created.id, + action: 'notification_requested', + afterData: { + eventType: input.eventType, + entityType: input.entityType, + entityId: input.entityId, + }, + }); + + return mapEventRecord(created); +} + +function buildTemplateFromOverride( + template: NotificationTemplateRecord, + override?: TemplateOverride, +): NotificationTemplateRecord { + if (!override) { + return template; + } + + return { + ...template, + subjectTemplate: override.subjectTemplate ?? template.subjectTemplate, + titleTemplate: override.titleTemplate ?? template.titleTemplate, + bodyTemplate: override.bodyTemplate ?? template.bodyTemplate, + bodyFormat: override.bodyFormat ?? template.bodyFormat, + linkTemplate: + override.linkTemplate === undefined + ? template.linkTemplate + : override.linkTemplate, + }; +} + +async function buildDispatchPreview( + input: NotificationPreviewRequest, +): Promise<{ + template: NotificationTemplateRecord; + attachments: ResolvedNotificationAttachment[]; + rendered: NotificationRenderResult; + providerKey: string; +}> { + const to = input.recipientOverride.to.map((recipient) => ({ + ...recipient, + email: normalizeEmailAddress(recipient.email), + })); + const cc = (input.recipientOverride.cc ?? []).map((recipient) => ({ + ...recipient, + email: normalizeEmailAddress(recipient.email), + })); + const bcc = (input.recipientOverride.bcc ?? []).map((recipient) => ({ + ...recipient, + email: normalizeEmailAddress(recipient.email), + })); + + assertRecipients(to, 'to'); + assertRecipients(cc, 'cc'); + assertRecipients(bcc, 'bcc'); + + const template = await loadTemplate( + input.organizationId, + input.eventType, + input.channel, + ); + + if (!template) { + throw new AuthError( + `Notification template not found for ${input.eventType} (${input.channel}).`, + 404, + ); + } + + const nextTemplate = buildTemplateFromOverride(template, input.templateOverride); + const rendered = renderNotificationTemplate( + { + subjectTemplate: nextTemplate.subjectTemplate, + titleTemplate: nextTemplate.titleTemplate, + bodyTemplate: nextTemplate.bodyTemplate, + bodyFormat: nextTemplate.bodyFormat, + linkTemplate: nextTemplate.linkTemplate, + }, + input.payload ?? {}, + ); + + const attachments = input.attachments?.length + ? await resolveNotificationAttachments({ + organizationId: input.organizationId, + userId: input.actorUserId, + attachments: input.attachments, + }) + : []; + + const providerKey = input.providerKey ?? (input.channel === 'email' ? 'smtp' : input.channel); + + return { template: nextTemplate, attachments, rendered, providerKey }; +} + +export async function previewNotification(input: NotificationPreviewRequest) { + const preview = await buildDispatchPreview(input); + + return { + eventType: input.eventType, + entityType: input.entityType, + entityId: input.entityId, + channel: input.channel, + providerKey: preview.providerKey, + template: preview.template, + rendered: preview.rendered, + recipients: input.recipientOverride, + attachments: preview.attachments.map(({ buffer: _buffer, ...attachment }) => attachment), + }; +} + +async function persistDispatch(input: { + request: NotificationSendRequest; + event: NotificationEventRecord; + template: NotificationTemplateRecord; + rendered: NotificationRenderResult; + attachments: ResolvedNotificationAttachment[]; + providerKey: string; +}) { + const [created] = await db + .insert(appNotificationDispatches) + .values({ + id: crypto.randomUUID(), + organizationId: input.request.organizationId, + eventId: input.event.id, + templateId: input.template.id, + entityType: input.request.entityType, + entityId: input.request.entityId, + channel: input.request.channel, + providerKey: input.providerKey, + status: 'pending', + recipientTo: input.request.recipientOverride.to, + recipientCc: input.request.recipientOverride.cc ?? [], + recipientBcc: input.request.recipientOverride.bcc ?? [], + replyTo: input.request.recipientOverride.replyTo ?? null, + subject: input.rendered.subject, + bodyText: input.rendered.bodyText, + bodyHtml: input.rendered.bodyHtml, + attachments: input.attachments.map(({ buffer: _buffer, ...attachment }) => attachment), + recipientUserId: input.request.recipientOverride.recipientUserId ?? null, + recipientAddress: input.request.recipientOverride.to[0]?.email ?? null, + requestedBy: input.request.actorUserId, + metadata: { + payload: input.request.payload ?? {}, + linkUrl: input.rendered.linkUrl, + attachmentRefs: input.request.attachments ?? [], + templateOverride: input.request.templateOverride ?? null, + }, + retryCount: 0, + maxRetryCount: input.request.maxRetryCount ?? 3, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + await auditAction({ + organizationId: input.request.organizationId, + userId: input.request.actorUserId, + entityType: 'app_notification_dispatch', + entityId: created.id, + action: 'notification_provider_selected', + afterData: { + providerKey: input.providerKey, + channel: input.request.channel, + recipientAddress: created.recipientAddress, + }, + }); + + return mapDispatchRecord(created); +} + +async function runDispatch(input: { + organizationId: string; + actorUserId: string; + dispatch: NotificationDispatchRecord; + attachments: ResolvedNotificationAttachment[]; +}) { + const provider = getNotificationProvider(input.dispatch.channel, input.dispatch.providerKey); + + await db + .update(appNotificationDispatches) + .set({ + status: 'sending', + lastAttemptAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(appNotificationDispatches.id, input.dispatch.id)); + + await auditAction({ + organizationId: input.organizationId, + userId: input.actorUserId, + entityType: 'app_notification_dispatch', + entityId: input.dispatch.id, + action: 'notification_send_started', + afterData: { + providerKey: input.dispatch.providerKey, + channel: input.dispatch.channel, + }, + }); + + const result = await executeNotificationDispatch({ + provider, + retryCount: input.dispatch.retryCount, + maxRetryCount: input.dispatch.maxRetryCount, + message: { + dispatchId: input.dispatch.id, + subject: input.dispatch.subject, + bodyText: input.dispatch.bodyText, + bodyHtml: input.dispatch.bodyHtml, + to: input.dispatch.recipientTo, + cc: input.dispatch.recipientCc, + bcc: input.dispatch.recipientBcc, + replyTo: input.dispatch.replyTo, + attachments: input.dispatch.attachments, + attachmentBuffers: input.attachments.map((attachment) => attachment.buffer), + }, + }); + + const [updated] = await db + .update(appNotificationDispatches) + .set({ + status: result.status, + retryCount: result.retryCount, + lastError: result.errorMessage, + lastAttemptAt: result.attemptedAt, + sentAt: result.sentAt, + metadata: { + ...(input.dispatch.metadata ?? {}), + providerResult: result.providerResult, + }, + updatedAt: new Date(), + }) + .where(eq(appNotificationDispatches.id, input.dispatch.id)) + .returning(); + + await db + .update(appNotificationEvents) + .set({ + status: result.status === 'sent' ? 'processed' : 'failed', + lastError: result.errorMessage, + processedAt: result.sentAt, + updatedAt: new Date(), + }) + .where(eq(appNotificationEvents.id, input.dispatch.eventId)); + + await auditAction({ + organizationId: input.organizationId, + userId: input.actorUserId, + entityType: 'app_notification_dispatch', + entityId: input.dispatch.id, + action: + result.status === 'sent' + ? 'notification_send_succeeded' + : 'notification_send_failed', + afterData: { + status: result.status, + retryCount: result.retryCount, + error: result.errorMessage, + }, + }); + + return mapDispatchRecord(updated); +} + +export async function sendNotification(input: NotificationSendRequest) { + const preview = await buildDispatchPreview(input); + const event = await ensureEvent({ + organizationId: input.organizationId, + eventType: input.eventType, + entityType: input.entityType, + entityId: input.entityId, + actorUserId: input.actorUserId, + payload: input.payload ?? {}, + }); + + const dispatch = await persistDispatch({ + request: input, + event, + template: preview.template, + rendered: preview.rendered, + attachments: preview.attachments, + providerKey: preview.providerKey, + }); + + return runDispatch({ + organizationId: input.organizationId, + actorUserId: input.actorUserId, + dispatch, + attachments: preview.attachments, + }); +} + +export async function listNotificationHistory( + organizationId: string, + filters: NotificationHistoryFilters, +) { + const page = Math.max(filters.page ?? 1, 1); + const limit = Math.min(Math.max(filters.limit ?? 20, 1), 100); + + const where = and( + eq(appNotificationDispatches.organizationId, organizationId), + ...(filters.channel && filters.channel !== 'all' + ? [eq(appNotificationDispatches.channel, filters.channel)] + : []), + ...(filters.status && filters.status !== 'all' + ? [eq(appNotificationDispatches.status, filters.status)] + : []), + ...(filters.eventType ? [eq(appNotificationEvents.eventType, filters.eventType)] : []), + ...(filters.entityType ? [eq(appNotificationDispatches.entityType, filters.entityType)] : []), + ...(filters.entityId ? [eq(appNotificationDispatches.entityId, filters.entityId)] : []), + ...(filters.search + ? [ + or( + ilike(appNotificationDispatches.subject, `%${filters.search}%`), + ilike(appNotificationDispatches.recipientAddress, `%${filters.search}%`), + )!, + ] + : []), + ); + + const [rows, totalRow] = await Promise.all([ + db + .select({ + dispatch: appNotificationDispatches, + }) + .from(appNotificationDispatches) + .innerJoin( + appNotificationEvents, + eq(appNotificationEvents.id, appNotificationDispatches.eventId), + ) + .where(where) + .orderBy(desc(appNotificationDispatches.createdAt)) + .limit(limit) + .offset((page - 1) * limit), + db + .select({ value: count() }) + .from(appNotificationDispatches) + .innerJoin( + appNotificationEvents, + eq(appNotificationEvents.id, appNotificationDispatches.eventId), + ) + .where(where), + ]); + + return { + items: rows.map((row) => mapDispatchRecord(row.dispatch)), + page, + limit, + totalItems: totalRow[0]?.value ?? 0, + totalPages: Math.max(Math.ceil((totalRow[0]?.value ?? 0) / limit), 1), + }; +} + +export async function getNotificationDispatchDetail( + organizationId: string, + dispatchId: string, +) { + const [row] = await db + .select({ + dispatch: appNotificationDispatches, + event: appNotificationEvents, + template: appNotificationTemplates, + }) + .from(appNotificationDispatches) + .innerJoin( + appNotificationEvents, + eq(appNotificationEvents.id, appNotificationDispatches.eventId), + ) + .leftJoin( + appNotificationTemplates, + eq(appNotificationTemplates.id, appNotificationDispatches.templateId), + ) + .where( + and( + eq(appNotificationDispatches.id, dispatchId), + eq(appNotificationDispatches.organizationId, organizationId), + ), + ) + .limit(1); + + if (!row) { + throw new AuthError('Notification history item not found.', 404); + } + + return { + dispatch: mapDispatchRecord(row.dispatch), + event: mapEventRecord(row.event), + template: row.template ? mapTemplateRecord(row.template) : null, + }; +} + +export async function resendNotification(input: { + organizationId: string; + actorUserId: string; + dispatchId: string; +}) { + const detail = await getNotificationDispatchDetail( + input.organizationId, + input.dispatchId, + ); + + const attachmentRefs = Array.isArray(detail.dispatch.metadata?.attachmentRefs) + ? detail.dispatch.metadata?.attachmentRefs + : []; + + const preview = await buildDispatchPreview({ + organizationId: input.organizationId, + actorUserId: input.actorUserId, + eventType: detail.event.eventType, + entityType: detail.dispatch.entityType, + entityId: detail.dispatch.entityId, + channel: detail.dispatch.channel, + payload: + (detail.dispatch.metadata?.payload as Record | undefined) ?? {}, + templateOverride: + (detail.dispatch.metadata?.templateOverride as TemplateOverride | undefined) ?? + undefined, + recipientOverride: { + to: detail.dispatch.recipientTo, + cc: detail.dispatch.recipientCc, + bcc: detail.dispatch.recipientBcc, + replyTo: detail.dispatch.replyTo, + recipientUserId: detail.dispatch.recipientUserId, + }, + attachments: attachmentRefs, + providerKey: detail.dispatch.providerKey, + }); + + const [created] = await db + .insert(appNotificationDispatches) + .values({ + id: crypto.randomUUID(), + organizationId: input.organizationId, + eventId: detail.event.id, + templateId: detail.template?.id ?? null, + entityType: detail.dispatch.entityType, + entityId: detail.dispatch.entityId, + channel: detail.dispatch.channel, + providerKey: detail.dispatch.providerKey, + status: 'pending', + recipientTo: detail.dispatch.recipientTo, + recipientCc: detail.dispatch.recipientCc, + recipientBcc: detail.dispatch.recipientBcc, + replyTo: detail.dispatch.replyTo, + subject: preview.rendered.subject, + bodyText: preview.rendered.bodyText, + bodyHtml: preview.rendered.bodyHtml, + attachments: preview.attachments.map(({ buffer: _buffer, ...attachment }) => attachment), + recipientUserId: detail.dispatch.recipientUserId, + recipientAddress: detail.dispatch.recipientAddress, + requestedBy: input.actorUserId, + metadata: detail.dispatch.metadata, + retryCount: 0, + maxRetryCount: detail.dispatch.maxRetryCount, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + await auditAction({ + organizationId: input.organizationId, + userId: input.actorUserId, + entityType: 'app_notification_dispatch', + entityId: created.id, + action: 'notification_resend', + afterData: { + originalDispatchId: detail.dispatch.id, + eventId: detail.event.id, + }, + }); + + return runDispatch({ + organizationId: input.organizationId, + actorUserId: input.actorUserId, + dispatch: mapDispatchRecord(created), + attachments: preview.attachments, + }); +} diff --git a/src/features/foundation/notifications/server/template-renderer.test.ts b/src/features/foundation/notifications/server/template-renderer.test.ts new file mode 100644 index 0000000..4630f95 --- /dev/null +++ b/src/features/foundation/notifications/server/template-renderer.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + extractTemplateVariables, + renderNotificationTemplate, + renderTemplateString, +} from './template-renderer'; + +test('extractTemplateVariables deduplicates placeholders across template', () => { + assert.deepEqual(extractTemplateVariables('Hello {{name}} {{name}} {{customer.code}}'), [ + 'name', + 'customer.code', + ]); +}); + +test('renderTemplateString replaces nested tokens and defaults missing values', () => { + assert.equal( + renderTemplateString('Quotation {{quotation.code}} for {{customerName}}', { + quotation: { code: 'QT-001' }, + customerName: 'ACME', + }), + 'Quotation QT-001 for ACME', + ); + + assert.equal( + renderTemplateString('Missing {{unknown}}', {}), + 'Missing -', + ); +}); + +test('renderNotificationTemplate generates html and plain text bodies', () => { + const rendered = renderNotificationTemplate( + { + subjectTemplate: 'Approved {{quotationCode}}', + titleTemplate: 'Approved {{quotationCode}}', + bodyTemplate: '

Hello {{customerName}}


Ready

', + bodyFormat: 'html', + linkTemplate: '/q/{{quotationId}}', + }, + { + quotationCode: 'QT-2026-001', + quotationId: 'quo-1', + customerName: 'ACME', + }, + ); + + assert.equal(rendered.subject, 'Approved QT-2026-001'); + assert.equal(rendered.bodyHtml, '

Hello ACME


Ready

'); + assert.match(rendered.bodyText, /Hello ACME/); + assert.equal(rendered.linkUrl, '/q/quo-1'); + assert.deepEqual(rendered.variables.sort(), [ + 'customerName', + 'quotationCode', + 'quotationId', + ]); +}); diff --git a/src/features/foundation/notifications/server/template-renderer.ts b/src/features/foundation/notifications/server/template-renderer.ts index ef7d0a2..9f9a7c2 100644 --- a/src/features/foundation/notifications/server/template-renderer.ts +++ b/src/features/foundation/notifications/server/template-renderer.ts @@ -1,4 +1,8 @@ -import type { NotificationTemplateRecord } from '../types'; +import type { + NotificationBodyFormat, + NotificationRenderResult, + NotificationTemplateRecord, +} from '../types'; const TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g; @@ -26,21 +30,71 @@ function resolveTokenValue(payload: Record, token: string): str return '-'; } -function renderString(template: string | null, payload: Record): string | null { +export function extractTemplateVariables(template: string | null): string[] { + if (!template) { + return []; + } + + return [...new Set(Array.from(template.matchAll(TOKEN_PATTERN), (match) => match[1]))]; +} + +export function renderTemplateString( + template: string | null, + payload: Record, +): string | null { if (!template) { return null; } - return template.replace(TOKEN_PATTERN, (_match, token: string) => resolveTokenValue(payload, token)); + return template.replace(TOKEN_PATTERN, (_match, token: string) => + resolveTokenValue(payload, token), + ); +} + +function toPlainText(body: string, format: NotificationBodyFormat): string { + if (format !== 'html') { + return body; + } + + return body + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<[^>]+>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); } export function renderNotificationTemplate( - template: Pick, - payload: Record -) { + template: Pick< + NotificationTemplateRecord, + | 'subjectTemplate' + | 'titleTemplate' + | 'bodyTemplate' + | 'bodyFormat' + | 'linkTemplate' + >, + payload: Record, +): NotificationRenderResult { + const subject = + renderTemplateString(template.subjectTemplate ?? template.titleTemplate, payload) ?? '-'; + const title = renderTemplateString(template.titleTemplate, payload) ?? subject; + const renderedBody = renderTemplateString(template.bodyTemplate, payload) ?? '-'; + const bodyHtml = template.bodyFormat === 'html' ? renderedBody : null; + const bodyText = toPlainText(renderedBody, template.bodyFormat); + return { - title: renderString(template.titleTemplate, payload) ?? '-', - body: renderString(template.bodyTemplate, payload) ?? '-', - linkUrl: renderString(template.linkTemplate, payload) + subject, + title, + bodyText: bodyText || '-', + bodyHtml, + linkUrl: renderTemplateString(template.linkTemplate, payload), + variables: [ + ...new Set([ + ...extractTemplateVariables(template.subjectTemplate), + ...extractTemplateVariables(template.titleTemplate), + ...extractTemplateVariables(template.bodyTemplate), + ...extractTemplateVariables(template.linkTemplate), + ]), + ], }; } diff --git a/src/features/foundation/notifications/types.ts b/src/features/foundation/notifications/types.ts index 7c2dc29..8d313fe 100644 --- a/src/features/foundation/notifications/types.ts +++ b/src/features/foundation/notifications/types.ts @@ -1,16 +1,69 @@ -export type NotificationChannel = 'in_app' | 'email' | 'line' | 'webhook'; -export type NotificationEventStatus = 'pending' | 'processed' | 'failed'; -export type NotificationStatus = 'unread' | 'read' | 'archived'; -export type NotificationSeverity = 'info' | 'success' | 'warning' | 'error'; +export const NOTIFICATION_CHANNELS = [ + 'in_app', + 'email', + 'teams', + 'line', + 'slack', + 'mobile_push', + 'sms', + 'web_notification', + 'webhook', +] as const; + +export const NOTIFICATION_EVENT_STATUSES = [ + 'pending', + 'processed', + 'failed', +] as const; + +export const NOTIFICATION_DISPATCH_STATUSES = [ + 'pending', + 'sending', + 'sent', + 'failed', + 'retry', +] as const; + +export const NOTIFICATION_INBOX_STATUSES = [ + 'unread', + 'read', + 'archived', +] as const; + +export const NOTIFICATION_BODY_FORMATS = ['plain', 'html'] as const; + +export const NOTIFICATION_SEVERITIES = [ + 'info', + 'success', + 'warning', + 'error', +] as const; + +export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number]; +export type NotificationEventStatus = + (typeof NOTIFICATION_EVENT_STATUSES)[number]; +export type NotificationDispatchStatus = + (typeof NOTIFICATION_DISPATCH_STATUSES)[number]; +export type NotificationStatus = (typeof NOTIFICATION_INBOX_STATUSES)[number]; +export type NotificationBodyFormat = + (typeof NOTIFICATION_BODY_FORMATS)[number]; +export type NotificationSeverity = (typeof NOTIFICATION_SEVERITIES)[number]; export type NotificationTemplateRecord = { id: string; organizationId: string; eventType: string; + code: string; + name: string; channel: NotificationChannel; + subjectTemplate: string | null; titleTemplate: string; bodyTemplate: string; + bodyFormat: NotificationBodyFormat; linkTemplate: string | null; + language: string; + variables: string[]; + version: number; isActive: boolean; metadata: Record | null; createdAt: string; @@ -54,6 +107,70 @@ export type AppNotificationRecord = { updatedAt: string; }; +export type NotificationDispatchRecipient = { + name?: string | null; + email: string; +}; + +export type NotificationAttachmentRef = + | { + type: 'artifact'; + artifactId: string; + label?: string | null; + } + | { + type: 'document_library_version'; + versionId: string; + label?: string | null; + } + | { + type: 'active_document_library'; + libraryId: string; + label?: string | null; + }; + +export type ResolvedNotificationAttachment = { + sourceType: NotificationAttachmentRef['type']; + sourceId: string; + fileName: string; + contentType: string; + size: number; + checksum: string | null; + label: string | null; + buffer: Buffer; +}; + +export type NotificationDispatchRecord = { + id: string; + organizationId: string; + eventId: string; + templateId: string | null; + entityType: string; + entityId: string; + channel: NotificationChannel; + providerKey: string; + status: NotificationDispatchStatus; + recipientTo: NotificationDispatchRecipient[]; + recipientCc: NotificationDispatchRecipient[]; + recipientBcc: NotificationDispatchRecipient[]; + replyTo: string | null; + subject: string; + bodyText: string; + bodyHtml: string | null; + attachments: Array>; + recipientUserId: string | null; + recipientAddress: string | null; + requestedBy: string; + metadata: Record | null; + lastError: string | null; + retryCount: number; + maxRetryCount: number; + lastAttemptAt: string | null; + sentAt: string | null; + createdAt: string; + updatedAt: string; +}; + export type NotificationRecipientType = | 'explicit_user' | 'approval_current_step_approvers' @@ -81,5 +198,85 @@ export type PublishNotificationEventInput = { export type NotificationListFilters = { page?: number; limit?: number; - status?: NotificationStatus | 'all'; + status?: 'all' | NotificationStatus; }; + +export type NotificationHistoryFilters = { + page?: number; + limit?: number; + channel?: NotificationChannel | 'all'; + status?: NotificationDispatchStatus | 'all'; + eventType?: string; + entityType?: string; + entityId?: string; + search?: string; +}; + +export type NotificationPreviewRequest = { + organizationId: string; + actorUserId: string; + eventType: string; + entityType: string; + entityId: string; + channel: NotificationChannel; + payload?: Record; + templateOverride?: Partial<{ + subjectTemplate: string; + titleTemplate: string; + bodyTemplate: string; + bodyFormat: NotificationBodyFormat; + linkTemplate: string | null; + }>; + recipientOverride: { + to: NotificationDispatchRecipient[]; + cc?: NotificationDispatchRecipient[]; + bcc?: NotificationDispatchRecipient[]; + replyTo?: string | null; + recipientUserId?: string | null; + }; + attachments?: NotificationAttachmentRef[]; + providerKey?: string; +}; + +export type NotificationSendRequest = NotificationPreviewRequest & { + maxRetryCount?: number; +}; + +export type NotificationRenderResult = { + subject: string; + title: string; + bodyText: string; + bodyHtml: string | null; + linkUrl: string | null; + variables: string[]; +}; + +export type NotificationProviderSendInput = { + dispatchId: string; + subject: string; + bodyText: string; + bodyHtml: string | null; + to: NotificationDispatchRecipient[]; + cc: NotificationDispatchRecipient[]; + bcc: NotificationDispatchRecipient[]; + replyTo: string | null; + attachments: Array>; + attachmentBuffers: Buffer[]; +}; + +export type NotificationProviderSendResult = { + providerMessageId: string | null; + acceptedRecipients: string[]; + rejectedRecipients: string[]; + metadata?: Record; +}; + +export interface NotificationProvider { + readonly channel: NotificationChannel; + readonly key: string; + validate(): Promise; + healthCheck(): Promise<{ ok: boolean; detail?: string }>; + send( + input: NotificationProviderSendInput, + ): Promise; +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index 79d277f..c8a3573 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -31,6 +31,10 @@ export const PERMISSIONS = { notificationsRead: 'notifications.read', notificationsUpdate: 'notifications.update', notificationsAdmin: 'notifications.admin', + notificationSend: 'notification.send', + notificationResend: 'notification.resend', + notificationManageTemplate: 'notification.manage_template', + notificationViewHistory: 'notification.view_history', reportRead: 'report:read', crmLeadRead: 'crm.lead.read', crmLeadCreate: 'crm.lead.create', @@ -615,7 +619,17 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [ permissions: [ { key: PERMISSIONS.notificationsRead, label: 'Read notifications' }, { key: PERMISSIONS.notificationsUpdate, label: 'Update notifications' }, - { key: PERMISSIONS.notificationsAdmin, label: 'Administer notifications' } + { key: PERMISSIONS.notificationsAdmin, label: 'Administer notifications' }, + { key: PERMISSIONS.notificationSend, label: 'Send notifications' }, + { key: PERMISSIONS.notificationResend, label: 'Resend notifications' }, + { + key: PERMISSIONS.notificationManageTemplate, + label: 'Manage notification templates', + }, + { + key: PERMISSIONS.notificationViewHistory, + label: 'View notification history', + } ] }, { @@ -709,11 +723,20 @@ export function getMembershipBasePermissions(role: MembershipRole): Permission[] PERMISSIONS.usersManage, PERMISSIONS.notificationsRead, PERMISSIONS.notificationsUpdate, - PERMISSIONS.notificationsAdmin + PERMISSIONS.notificationsAdmin, + PERMISSIONS.notificationSend, + PERMISSIONS.notificationResend, + PERMISSIONS.notificationManageTemplate, + PERMISSIONS.notificationViewHistory ]; } - return [PERMISSIONS.productsRead, PERMISSIONS.notificationsRead, PERMISSIONS.notificationsUpdate]; + return [ + PERMISSIONS.productsRead, + PERMISSIONS.notificationsRead, + PERMISSIONS.notificationsUpdate, + PERMISSIONS.notificationSend, + ]; } export function getRoleProfileDefinition(roleCode: string): CrmRoleProfileDefinition | null { diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/f409f25e-3521-4d37-8aec-34b28fed39de/SLA-ALLA-TH.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/f409f25e-3521-4d37-8aec-34b28fed39de/SLA-ALLA-TH.pdf new file mode 100644 index 0000000..c6c03b8 Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/f409f25e-3521-4d37-8aec-34b28fed39de/SLA-ALLA-TH.pdf differ diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/quotations/approved/02d6b4b3-b11c-4a6e-9518-1258c8fcf4a1-CRA2606-1107-approved.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/quotations/approved/02d6b4b3-b11c-4a6e-9518-1258c8fcf4a1-CRA2606-1107-approved.pdf new file mode 100644 index 0000000..60018ba Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/quotations/approved/02d6b4b3-b11c-4a6e-9518-1258c8fcf4a1-CRA2606-1107-approved.pdf differ diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/quotations/approved/f9290e40-0836-431d-9d9f-0ff25fa2bcc3-CRA2606-1108-approved.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/quotations/approved/f9290e40-0836-431d-9d9f-0ff25fa2bcc3-CRA2606-1108-approved.pdf new file mode 100644 index 0000000..4150be4 Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/quotations/approved/f9290e40-0836-431d-9d9f-0ff25fa2bcc3-CRA2606-1108-approved.pdf differ