ตรวจสอบแก้ไชสร้างเอกสาร

This commit is contained in:
phaichayon
2026-06-30 17:35:03 +07:00
parent a4fd166c51
commit e5766ea311
40 changed files with 8288 additions and 106 deletions

1
.gitignore vendored
View File

@@ -137,3 +137,4 @@ dist
.yarn/install-state.gz
.pnp.*
.storage

View File

@@ -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,

View File

@@ -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"
},
{

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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

View File

@@ -2,7 +2,7 @@
"audit": "template-version-integrity",
"overallStatus": "PASS",
"activeTemplateCount": 1,
"quotationCode": "CRA2606-1108",
"quotationCode": "CRA2606-1107",
"approvedTemplateVersionId": null,
"approvedTemplateVersionIsActive": false,
"issues": []

View File

@@ -1,7 +1,7 @@
{
"audit": "topic-runtime",
"overallStatus": "PASS",
"quotationCode": "CRA2606-1108",
"quotationCode": "CRA2606-1107",
"topicCount": 3,
"topicInputKeys": [
"topic_1_0",

View File

@@ -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": {

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"
);

View File

@@ -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");

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

21
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

329
plans/task-p.7.1.md Normal file
View File

@@ -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

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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(),

View File

@@ -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<Uint8Array>) {
return Buffer.from(await new Response(stream).arrayBuffer());
}
export async function resolveNotificationAttachments(input: {
organizationId: string;
userId: string;
attachments: NotificationAttachmentRef[];
}): Promise<ResolvedNotificationAttachment[]> {
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),
};
}),
);
}

View File

@@ -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.');
});

View File

@@ -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<NotificationDispatchExecutionResult> {
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,
};
}
}

View File

@@ -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<NotificationProviderSendResult> {
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,
},
};
}
}

View File

@@ -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<string, unknown> | 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',

View File

@@ -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.`);
}

View File

@@ -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<Omit<ResolvedNotificationAttachment, 'buffer'>> {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
if (!item || typeof item !== 'object') {
return [];
}
const candidate = item as Record<string, unknown>;
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
}) {
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<string, unknown> | 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,
});
}

View File

@@ -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: '<p>Hello {{customerName}}</p><br/>Ready</p>',
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, '<p>Hello ACME</p><br/>Ready</p>');
assert.match(rendered.bodyText, /Hello ACME/);
assert.equal(rendered.linkUrl, '/q/quo-1');
assert.deepEqual(rendered.variables.sort(), [
'customerName',
'quotationCode',
'quotationId',
]);
});

View File

@@ -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<string, unknown>, token: string): str
return '-';
}
function renderString(template: string | null, payload: Record<string, unknown>): 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, unknown>,
): 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(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function renderNotificationTemplate(
template: Pick<NotificationTemplateRecord, 'titleTemplate' | 'bodyTemplate' | 'linkTemplate'>,
payload: Record<string, unknown>
) {
template: Pick<
NotificationTemplateRecord,
| 'subjectTemplate'
| 'titleTemplate'
| 'bodyTemplate'
| 'bodyFormat'
| 'linkTemplate'
>,
payload: Record<string, unknown>,
): 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),
]),
],
};
}

View File

@@ -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<string, unknown> | 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<Omit<ResolvedNotificationAttachment, 'buffer'>>;
recipientUserId: string | null;
recipientAddress: string | null;
requestedBy: string;
metadata: Record<string, unknown> | 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<string, unknown>;
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<Omit<ResolvedNotificationAttachment, 'buffer'>>;
attachmentBuffers: Buffer[];
};
export type NotificationProviderSendResult = {
providerMessageId: string | null;
acceptedRecipients: string[];
rejectedRecipients: string[];
metadata?: Record<string, unknown>;
};
export interface NotificationProvider {
readonly channel: NotificationChannel;
readonly key: string;
validate(): Promise<void>;
healthCheck(): Promise<{ ok: boolean; detail?: string }>;
send(
input: NotificationProviderSendInput,
): Promise<NotificationProviderSendResult>;
}

View File

@@ -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 {