ตรวจสอบแก้ไชสร้างเอกสาร
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -137,3 +137,4 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.storage
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"audit": "template-version-integrity",
|
||||
"overallStatus": "PASS",
|
||||
"activeTemplateCount": 1,
|
||||
"quotationCode": "CRA2606-1108",
|
||||
"quotationCode": "CRA2606-1107",
|
||||
"approvedTemplateVersionId": null,
|
||||
"approvedTemplateVersionIsActive": false,
|
||||
"issues": []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"audit": "topic-runtime",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "CRA2606-1108",
|
||||
"quotationCode": "CRA2606-1107",
|
||||
"topicCount": 3,
|
||||
"topicInputKeys": [
|
||||
"topic_1_0",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
66
docs/implementation/task-p.7.1-implementation.md
Normal file
66
docs/implementation/task-p.7.1-implementation.md
Normal 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.
|
||||
41
docs/implementation/task-p.7.1-verification-report.md
Normal file
41
docs/implementation/task-p.7.1-verification-report.md
Normal 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.
|
||||
60
drizzle/0009_notification_delivery_foundation.sql
Normal file
60
drizzle/0009_notification_delivery_foundation.sql
Normal 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"
|
||||
);
|
||||
89
drizzle/0009_stale_next_avengers.sql
Normal file
89
drizzle/0009_stale_next_avengers.sql
Normal 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");
|
||||
5757
drizzle/meta/0009_snapshot.json
Normal file
5757
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
2
next-env.d.ts
vendored
@@ -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
21
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
329
plans/task-p.7.1.md
Normal 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
|
||||
36
src/app/api/notifications/history/[id]/resend/route.ts
Normal file
36
src/app/api/notifications/history/[id]/resend/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/notifications/history/[id]/route.ts
Normal file
29
src/app/api/notifications/history/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
48
src/app/api/notifications/history/route.ts
Normal file
48
src/app/api/notifications/history/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
92
src/app/api/notifications/preview/route.ts
Normal file
92
src/app/api/notifications/preview/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
96
src/app/api/notifications/send/route.ts
Normal file
96
src/app/api/notifications/send/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
134
src/features/foundation/notifications/server/email-provider.ts
Normal file
134
src/features/foundation/notifications/server/email-provider.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
733
src/features/foundation/notifications/server/service.ts
Normal file
733
src/features/foundation/notifications/server/service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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),
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user