From e5766ea3112646c8203821d2aeec67def92c5cc5 Mon Sep 17 00:00:00 2001
From: phaichayon
Date: Tue, 30 Jun 2026 17:35:03 +0700
Subject: [PATCH] =?UTF-8?q?=E0=B8=95=E0=B8=A3=E0=B8=A7=E0=B8=88=E0=B8=AA?=
=?UTF-8?q?=E0=B8=AD=E0=B8=9A=E0=B9=81=E0=B8=81=E0=B9=89=E0=B9=84=E0=B8=8A?=
=?UTF-8?q?=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B9=80=E0=B8=AD?=
=?UTF-8?q?=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
.../pdf-audit/payload-parity-report.json | 8 +-
.../pdf-audit/runtime-payload-report.json | 134 +-
.../pdf-audit/signature-audit-report.json | 8 +-
artifacts/pdf-audit/summary.json | 4 +-
artifacts/pdf-audit/summary.md | 4 +-
.../pdf-audit/template-version-report.json | 2 +-
artifacts/pdf-audit/topic-runtime-report.json | 2 +-
docs/implementation/pdf-audit-report.json | 6 +-
docs/implementation/pdf-audit-report.md | 4 +-
.../task-p.7.1-implementation.md | 66 +
.../task-p.7.1-verification-report.md | 41 +
.../0009_notification_delivery_foundation.sql | 60 +
drizzle/0009_stale_next_avengers.sql | 89 +
drizzle/meta/0009_snapshot.json | 5757 +++++++++++++++++
drizzle/meta/_journal.json | 9 +-
next-env.d.ts | 2 +-
package-lock.json | 21 +
package.json | 2 +
plans/task-p.7.1.md | 329 +
.../history/[id]/resend/route.ts | 36 +
.../api/notifications/history/[id]/route.ts | 29 +
src/app/api/notifications/history/route.ts | 48 +
src/app/api/notifications/preview/route.ts | 92 +
src/app/api/notifications/send/route.ts | 96 +
src/db/schema.ts | 46 +
.../server/attachment-service.ts | 83 +
.../server/delivery-engine.test.ts | 90 +
.../notifications/server/delivery-engine.ts | 62 +
.../notifications/server/email-provider.ts | 134 +
.../notifications/server/event-service.ts | 11 +-
.../notifications/server/provider-factory.ts | 21 +
.../notifications/server/service.ts | 733 +++
.../server/template-renderer.test.ts | 56 +
.../notifications/server/template-renderer.ts | 72 +-
.../foundation/notifications/types.ts | 207 +-
src/lib/auth/rbac.ts | 29 +-
.../SLA-ALLA-TH.pdf | Bin 0 -> 1391491 bytes
...518-1258c8fcf4a1-CRA2606-1107-approved.pdf | Bin 0 -> 135247 bytes
...d9f-0ff25fa2bcc3-CRA2606-1108-approved.pdf | Bin 0 -> 134979 bytes
40 files changed, 8288 insertions(+), 106 deletions(-)
create mode 100644 docs/implementation/task-p.7.1-implementation.md
create mode 100644 docs/implementation/task-p.7.1-verification-report.md
create mode 100644 drizzle/0009_notification_delivery_foundation.sql
create mode 100644 drizzle/0009_stale_next_avengers.sql
create mode 100644 drizzle/meta/0009_snapshot.json
create mode 100644 plans/task-p.7.1.md
create mode 100644 src/app/api/notifications/history/[id]/resend/route.ts
create mode 100644 src/app/api/notifications/history/[id]/route.ts
create mode 100644 src/app/api/notifications/history/route.ts
create mode 100644 src/app/api/notifications/preview/route.ts
create mode 100644 src/app/api/notifications/send/route.ts
create mode 100644 src/features/foundation/notifications/server/attachment-service.ts
create mode 100644 src/features/foundation/notifications/server/delivery-engine.test.ts
create mode 100644 src/features/foundation/notifications/server/delivery-engine.ts
create mode 100644 src/features/foundation/notifications/server/email-provider.ts
create mode 100644 src/features/foundation/notifications/server/provider-factory.ts
create mode 100644 src/features/foundation/notifications/server/service.ts
create mode 100644 src/features/foundation/notifications/server/template-renderer.test.ts
create mode 100644 storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/f409f25e-3521-4d37-8aec-34b28fed39de/SLA-ALLA-TH.pdf
create mode 100644 storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/quotations/approved/02d6b4b3-b11c-4a6e-9518-1258c8fcf4a1-CRA2606-1107-approved.pdf
create mode 100644 storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/quotations/approved/f9290e40-0836-431d-9d9f-0ff25fa2bcc3-CRA2606-1108-approved.pdf
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 0000000000000000000000000000000000000000..c6c03b87bbf865e041692db737ef9ecfae6290df
GIT binary patch
literal 1391491
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