diff --git a/artifacts/pdf-audit/payload-parity-report.json b/artifacts/pdf-audit/payload-parity-report.json index 20c9e06..3422dc9 100644 --- a/artifacts/pdf-audit/payload-parity-report.json +++ b/artifacts/pdf-audit/payload-parity-report.json @@ -1,10 +1,10 @@ { "audit": "payload-parity", "overallStatus": "PASS", - "quotationCode": "QT2606-1122", - "previewHash": "c3cc553fc210355e7f54726b414e7030be0a64604f6a175499e0b3787aef31a1", - "generationHash": "c3cc553fc210355e7f54726b414e7030be0a64604f6a175499e0b3787aef31a1", - "snapshotHash": "c3cc553fc210355e7f54726b414e7030be0a64604f6a175499e0b3787aef31a1", + "quotationCode": "CR2606-1124", + "previewHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7", + "generationHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7", + "snapshotHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7", "mismatches": [], "comparableFieldCount": 31, "topicInputKeyCount": 6, diff --git a/artifacts/pdf-audit/placeholder-integrity-report.json b/artifacts/pdf-audit/placeholder-integrity-report.json index 4799981..cbc8a95 100644 --- a/artifacts/pdf-audit/placeholder-integrity-report.json +++ b/artifacts/pdf-audit/placeholder-integrity-report.json @@ -3,8 +3,8 @@ "overallStatus": "PASS", "results": [ { - "templateName": "ONVALLA Quotation Standard", - "organizationId": "6bf90327-2ce9-4831-93bb-e8d026699ff2", + "templateName": "ALLA Demo Organization Quotation Standard", + "organizationId": "daf796fc-4588-5240-a27e-9bfef0739658", "version": "1.0", "duplicateFieldNames": [], "typoFields": [], @@ -13,18 +13,6 @@ "invalidReservedMappings": [], "missingReservedFields": [], "status": "PASS" - }, - { - "templateName": "ALLA Quotation Standard", - "organizationId": "d5c46139-9af4-421b-9395-d83620c62f9b", - "version": "2.1", - "duplicateFieldNames": [], - "typoFields": [], - "unmappedPlaceholders": [], - "deadMappings": [], - "invalidReservedMappings": [], - "missingReservedFields": [], - "status": "PASS" } ] } diff --git a/artifacts/pdf-audit/runtime-payload-report.json b/artifacts/pdf-audit/runtime-payload-report.json index c2a8be7..3aa742a 100644 --- a/artifacts/pdf-audit/runtime-payload-report.json +++ b/artifacts/pdf-audit/runtime-payload-report.json @@ -2,51 +2,51 @@ "audit": "runtime-payload", "overallStatus": "PASS", "staticLabelsStatus": "PASS", - "quotationCode": "QT2606-1122", - "templateName": "ALLA Quotation Standard", - "templateVersion": "2.1", + "quotationCode": "CR2606-1124", + "templateName": "ALLA Demo Organization Quotation Standard", + "templateVersion": "1.0", "findingCount": 168, "findings": [ { "field": "documentData.company.id", "source": "documentData", "status": "PASS", - "value": "d5c46139-9af4-421b-9395-d83620c62f9b", + "value": "daf796fc-4588-5240-a27e-9bfef0739658", "message": "String is populated" }, { "field": "documentData.company.name", "source": "documentData", "status": "PASS", - "value": "ALLA", + "value": "ALLA Demo Organization", "message": "String is populated" }, { "field": "documentData.customer.id", "source": "documentData", "status": "PASS", - "value": "56f1f9a3-6204-4ccc-af0f-5dbf866d5a56", + "value": "96954c53-fd37-4d97-80e0-1809756a015d", "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": "Mueang Chiang Mai, Chiang Mai", + "value": "Bang Sue, Bangkok", "message": "String is populated" }, { "field": "documentData.customer.phone", "source": "documentData", "status": "PASS", - "value": "02-400824", + "value": "02-400827", "message": "String is populated" }, { @@ -60,14 +60,14 @@ "field": "documentData.contact.id", "source": "documentData", "status": "PASS", - "value": "93843e06-65db-492f-9229-066e6dfdec5d", + "value": "3fc91684-ebed-49a9-b50e-0fb6fdaae87f", "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": "0810000220", + "value": "0810000250", "message": "String is populated" }, { "field": "documentData.contact.position", "source": "documentData", "status": "PASS", - "value": "Plant Manager", + "value": "Procurement Manager", "message": "String is populated" }, { "field": "documentData.quotation.id", "source": "documentData", "status": "PASS", - "value": "7dc0630b-e27d-4696-803a-d93f51bb9804", + "value": "bb3ebe66-a0ed-4b48-b125-795b549fa798", "message": "String is populated" }, { "field": "documentData.quotation.code", "source": "documentData", "status": "PASS", - "value": "QT2606-1122", + "value": "CR2606-1124", "message": "String is populated" }, { "field": "documentData.quotation.projectName", "source": "documentData", "status": "PASS", - "value": "Dealer Stock Crane Package", + "value": "Material Handling Lab Crane", "message": "String is populated" }, { "field": "documentData.quotation.projectLocation", "source": "documentData", "status": "PASS", - "value": "Chiang Mai Service Warehouse", + "value": "Bang Sue Engineering Faculty", "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:dealer-channel-approved", + "value": "crm-uat-seed-v1:university-lab-crane-approved", "message": "String is populated" }, { @@ -151,14 +151,14 @@ "field": "documentData.quotation.subtotal", "source": "documentData", "status": "PASS", - "value": 4418000, + "value": 3008000, "message": "Scalar value is populated" }, { "field": "documentData.quotation.totalAmount", "source": "documentData", "status": "PASS", - "value": 4585442, + "value": 3122003, "message": "Scalar value is populated" }, { @@ -172,7 +172,7 @@ "field": "documentData.items[0].id", "source": "documentData", "status": "PASS", - "value": "7b2a99ab-5106-4ab1-b611-cd63957aa38f", + "value": "ab4102fc-7ad9-4338-aee2-b847c10dbf15", "message": "String is populated" }, { @@ -186,7 +186,7 @@ "field": "documentData.items[0].description", "source": "documentData", "status": "PASS", - "value": "Main equipment supply for Dealer Stock Crane Package", + "value": "Main equipment supply for Material Handling Lab Crane", "message": "String is populated" }, { @@ -207,7 +207,7 @@ "field": "documentData.items[0].unitPrice", "source": "documentData", "status": "PASS", - "value": 2521993, + "value": 1717102, "message": "Scalar value is populated" }, { @@ -221,14 +221,14 @@ "field": "documentData.items[0].totalPrice", "source": "documentData", "status": "PASS", - "value": 2521993, + "value": 1717102, "message": "Scalar value is populated" }, { "field": "documentData.items[1].id", "source": "documentData", "status": "PASS", - "value": "cc058b8e-2ce1-4e66-a72c-6c40474cf962", + "value": "9c320ba4-cf4b-4b3b-b72e-c588c8723384", "message": "String is populated" }, { @@ -242,7 +242,7 @@ "field": "documentData.items[1].description", "source": "documentData", "status": "PASS", - "value": "Installation and commissioning for Dealer Stock Crane Package", + "value": "Installation and commissioning for Material Handling Lab Crane", "message": "String is populated" }, { @@ -263,7 +263,7 @@ "field": "documentData.items[1].unitPrice", "source": "documentData", "status": "PASS", - "value": 1283924, + "value": 874161, "message": "Scalar value is populated" }, { @@ -277,14 +277,14 @@ "field": "documentData.items[1].totalPrice", "source": "documentData", "status": "PASS", - "value": 1283924, + "value": 874161, "message": "Scalar value is populated" }, { "field": "documentData.items[2].id", "source": "documentData", "status": "PASS", - "value": "9436e5e6-6dc7-4fa6-bf8a-da2727992680", + "value": "7f64760c-65d6-4b23-9921-4a33d43a2efc", "message": "String is populated" }, { @@ -298,7 +298,7 @@ "field": "documentData.items[2].description", "source": "documentData", "status": "PASS", - "value": "Operator training and documentation for Dealer Stock Crane Package", + "value": "Operator training and documentation for Material Handling Lab Crane", "message": "String is populated" }, { @@ -319,7 +319,7 @@ "field": "documentData.items[2].unitPrice", "source": "documentData", "status": "PASS", - "value": 458544, + "value": 312200, "message": "Scalar value is populated" }, { @@ -333,14 +333,14 @@ "field": "documentData.items[2].totalPrice", "source": "documentData", "status": "PASS", - "value": 458544, + "value": 312200, "message": "Scalar value is populated" }, { "field": "documentData.items[3].id", "source": "documentData", "status": "PASS", - "value": "63d82897-1660-4f49-b0d1-fddb9712922f", + "value": "4b5a4cef-3005-4253-9638-8c3cc9fade15", "message": "String is populated" }, { @@ -354,7 +354,7 @@ "field": "documentData.items[3].description", "source": "documentData", "status": "PASS", - "value": "Project management and testing for Dealer Stock Crane Package", + "value": "Project management and testing for Material Handling Lab Crane", "message": "String is populated" }, { @@ -375,7 +375,7 @@ "field": "documentData.items[3].unitPrice", "source": "documentData", "status": "PASS", - "value": 320981, + "value": 218540, "message": "Scalar value is populated" }, { @@ -389,7 +389,7 @@ "field": "documentData.items[3].totalPrice", "source": "documentData", "status": "PASS", - "value": 320981, + "value": 218540, "message": "Scalar value is populated" }, { @@ -403,21 +403,21 @@ "field": "documentData.quotationCustomers[0].id", "source": "documentData", "status": "PASS", - "value": "7a20818a-48a1-4eed-a779-ac5ec9d6159b", + "value": "04ff8d80-b200-4c01-8081-24d548a2a576", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].customerId", "source": "documentData", "status": "PASS", - "value": "56f1f9a3-6204-4ccc-af0f-5dbf866d5a56", + "value": "96954c53-fd37-4d97-80e0-1809756a015d", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].role", "source": "documentData", "status": "PASS", - "value": "09320e31-e8f9-42cd-a3ea-577210f29501", + "value": "a5d93b6c-131b-4e9e-9240-ac94fe12f3d8", "message": "String is populated" }, { @@ -438,7 +438,7 @@ "field": "documentData.topics.all[0].id", "source": "documentData", "status": "PASS", - "value": "ef076be2-fbef-444a-b7cf-cc2523e8b7dc", + "value": "58c2d002-1c6f-40c4-b68a-c11ece1fff33", "message": "String is populated" }, { @@ -473,7 +473,7 @@ "field": "documentData.topics.all[0].items[0].content", "source": "documentData", "status": "PASS", - "value": "Supply and execute dealer stock crane package according to approved drawing.", + "value": "Supply and execute material handling lab crane according to approved drawing.", "message": "String is populated" }, { @@ -501,7 +501,7 @@ "field": "documentData.topics.all[1].id", "source": "documentData", "status": "PASS", - "value": "14f41b98-f9d8-463b-9880-8e96d258a6ba", + "value": "5a069f2d-dfb1-4ec0-9f51-3f9b657e7731", "message": "String is populated" }, { @@ -564,7 +564,7 @@ "field": "documentData.topics.all[2].id", "source": "documentData", "status": "PASS", - "value": "54500c11-5f51-49d6-947e-db2f4d689959", + "value": "d16b5a95-d811-4860-9670-596bef0c13df", "message": "String is populated" }, { @@ -634,7 +634,7 @@ "field": "documentData.topics.exclusion[0].id", "source": "documentData", "status": "PASS", - "value": "14f41b98-f9d8-463b-9880-8e96d258a6ba", + "value": "5a069f2d-dfb1-4ec0-9f51-3f9b657e7731", "message": "String is populated" }, { @@ -732,14 +732,14 @@ "field": "documentData.pdfme.quotation_date", "source": "documentData", "status": "PASS", - "value": "Jun 3, 2026", + "value": "Jun 15, 2026", "message": "String is populated" }, { "field": "documentData.pdfme.quotation_price", "source": "documentData", "status": "PASS", - "value": "THB 4,418,000.00", + "value": "THB 3,008,000.00", "message": "String is populated" }, { @@ -802,49 +802,49 @@ "field": "documentData.signatures.preparedBy.name", "source": "documentData", "status": "PASS", - "value": "Super Admin", + "value": "Sales Crane UAT", "message": "String is populated" }, { "field": "documentData.signatures.preparedBy.position", "source": "documentData", "status": "PASS", - "value": "Sales Manager", + "value": "Sales Engineer", "message": "String is populated" }, { "field": "documentData.signatures.preparedBy.actedAt", "source": "documentData", "status": "PASS", - "value": "2026-06-03T09:00:00.000Z", + "value": "2026-06-15T09:00:00.000Z", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.name", "source": "documentData", "status": "PASS", - "value": "Super Admin", + "value": "-", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.position", "source": "documentData", "status": "PASS", - "value": "Sales Manager", + "value": "-", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.actedAt", "source": "documentData", - "status": "PASS", - "value": "2026-06-06T10:00:00.000Z", - "message": "String is populated" + "status": "WARNING", + "value": null, + "message": "Value is null" }, { "field": "documentData.signatures.authorizedBy.name", "source": "documentData", "status": "PASS", - "value": "Super Admin", + "value": "Sales Manager UAT", "message": "String is populated" }, { @@ -858,28 +858,28 @@ "field": "documentData.signatures.authorizedBy.actedAt", "source": "documentData", "status": "PASS", - "value": "2026-06-07T09:00:00.000Z", + "value": "2026-06-17T11: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": "Mueang Chiang Mai, Chiang Mai", + "value": "Bang Sue, Bangkok", "message": "String is populated" }, { "field": "templateInput.customer_tel", "source": "templateInput", "status": "PASS", - "value": "02-400824", + "value": "02-400827", "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": "Dealer Stock Crane Package", + "value": "Material Handling Lab Crane", "message": "String is populated" }, { "field": "templateInput.site_location", "source": "templateInput", "status": "PASS", - "value": "Chiang Mai Service Warehouse", + "value": "Bang Sue Engineering Faculty", "message": "String is populated" }, { "field": "templateInput.quotation_date_data", "source": "templateInput", "status": "PASS", - "value": "Jun 3, 2026", + "value": "Jun 15, 2026", "message": "String is populated" }, { "field": "templateInput.quotation_code_data", "source": "templateInput", "status": "PASS", - "value": "QT2606-1122", + "value": "CR2606-1124", "message": "String is populated" }, { @@ -935,7 +935,7 @@ "field": "templateInput.quotation_price", "source": "templateInput", "status": "PASS", - "value": "THB 4,418,000.00", + "value": "THB 3,008,000.00", "message": "String is populated" }, { @@ -984,35 +984,35 @@ "field": "templateInput.app1", "source": "templateInput", "status": "PASS", - "value": "Super Admin", + "value": "Sales Crane UAT", "message": "String is populated" }, { "field": "templateInput.app1_position", "source": "templateInput", "status": "PASS", - "value": "Sales Manager", + "value": "Sales Engineer", "message": "String is populated" }, { "field": "templateInput.app2", "source": "templateInput", "status": "PASS", - "value": "Super Admin", + "value": "-", "message": "String is populated" }, { "field": "templateInput.app2_position", "source": "templateInput", "status": "PASS", - "value": "Sales Manager", + "value": "-", "message": "String is populated" }, { "field": "templateInput.app3", "source": "templateInput", "status": "PASS", - "value": "Super Admin", + "value": "Sales Manager UAT", "message": "String is populated" }, { @@ -1026,7 +1026,7 @@ "field": "templateInput.quotation_date", "source": "templateInput", "status": "PASS", - "value": "Jun 3, 2026", + "value": "Jun 15, 2026", "message": "String is populated" }, { @@ -1075,7 +1075,7 @@ "field": "templateInput.quotation_code", "source": "templateInput", "status": "PASS", - "value": "QT2606-1122", + "value": "CR2606-1124", "message": "String is populated" }, { @@ -1138,7 +1138,7 @@ "field": "templateInput.quotation_price_data[0][1]", "source": "templateInput", "status": "PASS", - "value": "฿4,418,000.00", + "value": "฿3,008,000.00", "message": "Price table amount cell is formatted" }, { diff --git a/artifacts/pdf-audit/signature-audit-report.json b/artifacts/pdf-audit/signature-audit-report.json index 2d916ae..a35bc82 100644 --- a/artifacts/pdf-audit/signature-audit-report.json +++ b/artifacts/pdf-audit/signature-audit-report.json @@ -1,14 +1,14 @@ { "audit": "approved-snapshot-parity", "overallStatus": "PASS", - "quotationCode": "QT2606-1122", + "quotationCode": "CR2606-1124", "approvedSnapshotAvailable": true, - "approvedTemplateVersionId": "86f99414-4656-44a9-9937-8457794dc710", - "computedTemplateVersionId": "86f99414-4656-44a9-9937-8457794dc710", + "approvedTemplateVersionId": "850b13a2-c9ae-4992-89e7-a43f50c34378", + "computedTemplateVersionId": "850b13a2-c9ae-4992-89e7-a43f50c34378", "inconsistentTemplateKeys": [], - "computedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269", - "storedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269", - "approvedArtifactReference": "artifact:ec300b87-3710-43a4-a63c-a72168fe3933", + "computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229", + "storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229", + "approvedArtifactReference": null, "refreshedFixtureSnapshot": true, "usedAuditFixture": false } diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json index 733c825..6ab87a5 100644 --- a/artifacts/pdf-audit/summary.json +++ b/artifacts/pdf-audit/summary.json @@ -1,7 +1,7 @@ { - "generatedAt": "2026-06-30T01:35:49.157Z", + "generatedAt": "2026-06-30T02:56:03.783Z", "overallStatus": "PASS", - "quotationCode": "QT2606-1122", + "quotationCode": "CR2606-1124", "artifacts": { "templateVersionReport": "template-version-report.json", "placeholderIntegrityReport": "placeholder-integrity-report.json", diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md index 14e987f..a5b06c7 100644 --- a/artifacts/pdf-audit/summary.md +++ b/artifacts/pdf-audit/summary.md @@ -1,8 +1,8 @@ # PDF Integrity Summary -- Generated At: 2026-06-30T01:35:49.157Z +- Generated At: 2026-06-30T02:56:03.783Z - Overall Status: PASS -- Quotation Code: QT2606-1122 +- Quotation Code: CR2606-1124 ## Statuses diff --git a/artifacts/pdf-audit/template-version-report.json b/artifacts/pdf-audit/template-version-report.json index 1d9813e..090fa52 100644 --- a/artifacts/pdf-audit/template-version-report.json +++ b/artifacts/pdf-audit/template-version-report.json @@ -1,8 +1,8 @@ { "audit": "template-version-integrity", "overallStatus": "PASS", - "activeTemplateCount": 2, - "quotationCode": "QT2606-1122", + "activeTemplateCount": 1, + "quotationCode": "CR2606-1124", "approvedTemplateVersionId": null, "approvedTemplateVersionIsActive": false, "issues": [] diff --git a/artifacts/pdf-audit/topic-runtime-report.json b/artifacts/pdf-audit/topic-runtime-report.json index 76630bb..d324084 100644 --- a/artifacts/pdf-audit/topic-runtime-report.json +++ b/artifacts/pdf-audit/topic-runtime-report.json @@ -1,7 +1,7 @@ { "audit": "topic-runtime", "overallStatus": "PASS", - "quotationCode": "QT2606-1122", + "quotationCode": "CR2606-1124", "topicCount": 3, "topicInputKeys": [ "topic_1_0", diff --git a/docs/audit/package-scripts-cleanup.md b/docs/audit/package-scripts-cleanup.md new file mode 100644 index 0000000..a91a43d --- /dev/null +++ b/docs/audit/package-scripts-cleanup.md @@ -0,0 +1,88 @@ +# Package Scripts Cleanup Audit + +## Scope + +- reviewed `package.json` +- reviewed `scripts/**` +- reviewed `src/db/seeds/**` +- reviewed `docs/operations/system-bootstrap.md` +- cross-checked historical references in `docs/implementation/**` and setup copy + +## Decision Summary + +| Script Name | Current Command | Status | Reason | Replacement Command | +| --- | --- | --- | --- | --- | +| `dev` | `next dev` | keep | core runtime entrypoint | - | +| `build` | `npm run verify:encoding && next build` | keep | build already guards encoding drift | - | +| `start` | `next start` | keep | production runtime entrypoint | - | +| `lint` | `oxlint` | keep | active lint entrypoint | - | +| `lint:fix` | `oxlint --fix && bun format` | keep | active fix workflow | - | +| `lint:strict` | `oxlint --deny-warnings` | keep | stricter CI-style lint path | - | +| `format` | `oxfmt --write .` | keep | active formatter entrypoint | - | +| `format:check` | `oxfmt --check .` | keep | active formatter verification | - | +| `prepare` | `husky` | keep | install hook bootstrap | - | +| `typecheck` | `tsc --noEmit` | add | required by task and docs verification flow | - | +| `gen` | `npm run verify:encoding && npx drizzle-kit generate` | remove | duplicate of `db:generate` with unclear shorthand | `npm run db:generate` | +| `db:generate` | `npx drizzle-kit generate` | keep | canonical drizzle generate command | - | +| `db:migrate` | `npx drizzle-kit migrate` | keep | canonical drizzle migrate command | - | +| `db:studio` | `npx drizzle-kit studio` | keep | canonical drizzle studio command | - | +| `db:reset` | `node --experimental-strip-types scripts/db/reset-database.ts` | keep | guarded destructive operation with env safety | - | +| `db:setup` | `npm run db:migrate && npm run seed:system` | keep | still referenced by setup flow and valid | - | +| `db:setup:uat` | `npm run db:setup && npm run seed:uat` | keep | valid setup composition for UAT | - | +| `db:fresh` | `npm run db:reset && npm run db:migrate && npm run seed:system` | keep | clean local bootstrap path | - | +| `db:fresh:uat` | `npm run db:fresh && npm run seed:uat` | keep | clean UAT bootstrap path | - | +| `migrate` | `npm run db:migrate` | remove | duplicate shorthand | `npm run db:migrate` | +| `studio` | `npm run db:studio` | remove | duplicate shorthand | `npm run db:studio` | +| `setup:db` | `npm run db:setup` | keep | compatibility alias still referenced by setup wizard copy | `npm run db:setup` | +| `seed:super-admin` | `node scripts/seed-super-admin.js` | keep | useful low-level bootstrap step | - | +| `seed:foundation` | `node --experimental-strip-types src/db/seeds/foundation.seed.ts` | keep | useful low-level seed for troubleshooting | - | +| `seed:crm-uat` | `node --experimental-strip-types src/db/seeds/crm-uat.seed.ts` | keep | useful low-level data-only seed for troubleshooting | - | +| `seed:system` | old direct seed file | replace | now routes through `scripts/seed-system.ts` wrapper and includes PDF template reseed | `npm run seed:system` | +| `seed:uat` | old direct seed file | replace | now routes through `scripts/seed-uat.ts` wrapper and seeds users before CRM data | `npm run seed:uat` | +| `seed:reset` | not present | add | task-required guarded reset + migrate + reseed orchestration | `npm run seed:reset -- --allow-db-reset` | +| `seed:pdf-template` | `node --experimental-strip-types scripts/reseed-pdf-template-version.ts` | keep | canonical operator-facing PDF template sync | - | +| `seed:pdf-template-version` | same as `seed:pdf-template` | keep | compatibility alias for historical docs and task lineage | `npm run seed:pdf-template` | +| `pdf:reseed` | duplicate reseed alias | remove | overlapping alias with no added behavior | `npm run seed:pdf-template` | +| `pdf:activate:product` | targeted activation command | keep | still useful for product-v1 operator flow | - | +| `migrate:membership-business-roles` | migration utility | keep | one-off but still valid migration helper | - | +| `seed:task-h1-fixture` | fixture utility | keep | still referenced by historical PDF verification docs | - | +| `legacy:seed:task-h1-fixture` | duplicate legacy alias | remove | duplicate of active fixture command | `npm run seed:task-h1-fixture` | +| `verify:task-h1` | fixture verification utility | keep | still referenced by historical docs | - | +| `legacy:verify:task-h1` | duplicate legacy alias | remove | duplicate of active verification command | `npm run verify:task-h1` | +| `verify:task-h3` | historical verification utility | keep | still valid and referenced | - | +| `legacy:verify:task-h3` | duplicate legacy alias | remove | duplicate of active verification command | `npm run verify:task-h3` | +| `verify:encoding` | `node scripts/verify-encoding.mjs` | keep | build safety prerequisite | - | +| `verify:crm-access` | CRM access audit | keep | task-required verification | - | +| `audit:pdf:*` | PDF audit suite | keep | active verification foundation | - | + +## Changes Applied + +- added `typecheck` +- added `seed:reset` +- moved `seed:system` to `scripts/seed-system.ts` +- moved `seed:uat` to `scripts/seed-uat.ts` +- kept `seed:pdf-template-version` only as compatibility alias +- removed duplicate shorthand and legacy aliases: + - `gen` + - `migrate` + - `studio` + - `pdf:reseed` + - `legacy:seed:task-h1-fixture` + - `legacy:verify:task-h1` + - `legacy:verify:task-h3` + +## Seed Architecture Notes + +- `seed:system` now guarantees: + - super admin + - foundation data + - active PDF template reseed +- `seed:uat` now guarantees: + - deterministic UAT users and memberships + - CRM role profiles and role assignments + - CRM demo dataset +- `seed:reset` now guarantees: +- explicit `--allow-db-reset` confirmation or `ALLOW_DB_RESET=true` + - database reset safety guard + - migrations reapplied before seeding + - `seed:system` then `seed:uat` diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json index 181c1dd..bb681f0 100644 --- a/docs/implementation/pdf-audit-report.json +++ b/docs/implementation/pdf-audit-report.json @@ -1,30 +1,61 @@ { - "generatedAt": "2026-06-30T01:35:48.376Z", + "generatedAt": "2026-06-30T02:56:00.835Z", "overallStatus": "PASS", - "quotationCode": "QT2606-1122", + "quotationCode": "CR2606-1124", "expectedFixtureCode": "QT-H5-AUDIT", "template": { - "templateName": "ALLA Quotation Standard", - "version": "2.1", - "versionId": "86f99414-4656-44a9-9937-8457794dc710", + "templateName": "ALLA Demo Organization Quotation Standard", + "version": "1.0", + "versionId": "850b13a2-c9ae-4992-89e7-a43f50c34378", "fieldCount": 44, - "previousVersion": "2.0", - "deletedFields": [ - "__section_role__customer", - "__section_role__product_items", - "__section_role__signature", - "__section_role__topics", - "items_table", - "product_items_currency_value", - "product_items_footer_note", - "product_items_line_168", - "product_items_line_35", - "product_items_subtitle", - "product_items_title", - "product_items_total_label", - "product_items_total_value" + "previousVersion": null, + "deletedFields": [], + "newFields": [ + "Please_do_not", + "app1", + "app1_position", + "app2", + "app2_position", + "app3", + "app3_position", + "att_label", + "colon", + "colon copy", + "colon_att", + "colon_email", + "colon_fax", + "colon_project", + "colon_site", + "colon_tel", + "company1", + "company2", + "company_addr1", + "company_addr2", + "company_email", + "company_tax", + "company_tel", + "customer_addr", + "customer_att", + "customer_email", + "customer_name", + "customer_tel", + "data_topic", + "dear_sirs", + "email_label", + "line", + "project_label", + "project_name", + "quotation_code_data", + "quotation_code_lable", + "quotation_date_data", + "quotation_date_labe", + "quotation_price_data", + "site_label", + "site_location", + "tel_label", + "topic", + "yours_faithfuly" ], - "newFields": [], "duplicateFields": [], "unknownFields": [], "classifications": [ @@ -232,7 +263,7 @@ "consistency": { "status": "PASS", "approvedSnapshotAvailable": true, - "approvedArtifactReference": "artifact:ec300b87-3710-43a4-a63c-a72168fe3933", + "approvedArtifactReference": null, "inconsistentKeys": [] }, "integritySummary": { diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md index 7e793a3..76807ff 100644 --- a/docs/implementation/pdf-audit-report.md +++ b/docs/implementation/pdf-audit-report.md @@ -1,10 +1,10 @@ # PDF Audit Report -- Generated At: 2026-06-30T01:35:48.376Z +- Generated At: 2026-06-30T02:56:00.835Z - Overall Status: PASS -- Quotation Code: QT2606-1122 -- Template: ALLA Quotation Standard v2.1 -- Previous Version: 2.0 +- Quotation Code: CR2606-1124 +- Template: ALLA Demo Organization Quotation Standard v1.0 +- Previous Version: None ## Metrics diff --git a/docs/operations/system-bootstrap.md b/docs/operations/system-bootstrap.md index 7bcdbe5..45d972d 100644 --- a/docs/operations/system-bootstrap.md +++ b/docs/operations/system-bootstrap.md @@ -4,15 +4,21 @@ ```bash npm install -ALLOW_DB_RESET=true npm run db:fresh -npm exec tsc --noEmit +npm run db:fresh +npm run typecheck npm run dev ``` ## Fresh UAT or demo data ```bash -ALLOW_DB_RESET=true npm run db:fresh:uat +npm run db:fresh:uat +``` + +## Reset and reseed existing local/UAT data + +```bash +npm run seed:reset -- --allow-db-reset ``` ## Existing database upgrade @@ -44,7 +50,7 @@ npm run seed:pdf-template ## Verification ```bash -npm exec tsc --noEmit +npm run typecheck npm run build npm run verify:encoding npm run verify:crm-access diff --git a/docs/seeding/uat-seed-guide.md b/docs/seeding/uat-seed-guide.md new file mode 100644 index 0000000..39ad2de --- /dev/null +++ b/docs/seeding/uat-seed-guide.md @@ -0,0 +1,86 @@ +# UAT Seed Guide + +## Purpose + +Seeded UAT data is designed to make the CRM flow visible end-to-end: + +`Marketing (MK) -> Sales -> Manager -> CEO` + +Default password for all seeded UAT accounts: + +```text +UatDemo123! +``` + +## Seeded Users + +| User Email | Role | What this user should see | Expected CRM data | Expected approval actions | +| --- | --- | --- | --- | --- | +| `mk.uat@alla.local` | Marketing | lead intake, assigned lead queue, follow-up workload, dashboard without final commercial authority | newly created and assigned leads including crane and dock door stories | assign leads to sales, monitor follow-up only | +| `sales.crane.uat@alla.local` | Sales - Crane | own crane opportunities and quotations, pricing-visible commercial pipeline for crane work | crane project stories such as Siam Manufacturing / Toyota Gateway Plant | submit crane quotation for approval, track follow-up | +| `sales.dockdoor.uat@alla.local` | Sales - Dock Door | own dock door opportunities and quotations, follow-up workload | dock door stories such as Eastern Logistics / WHA Warehouse | submit dock door quotation, maintain follow-up and revisions | +| `mgr.sales.uat@alla.local` | Sales Manager + Department Manager | team-wide quotation queue, approval steps 1 and 2, broader sales pipeline | pending manager approval, manager-approved waiting CEO, revised quotations | approve, reject, return, and advance quotations | +| `ceo.uat@alla.local` | CEO / Top Manager | executive dashboard, organization-wide quotations, final approval queue | manager-approved quotations waiting final decision, approved deal visibility | final approve or reject quotations | +| `admin.uat@alla.local` | CRM Admin | full setup/admin visibility including master options, sequences, approvals, document templates | complete seeded organization data | verify configuration, inspect all data, no special workflow dependency | + +## Seeded Workflow Scenarios + +### Scenario A: Crane Project + +- Customer: `Siam Manufacturing Co., Ltd.` +- End Customer: `Toyota Gateway Plant` +- Project: `Overhead Crane Installation Phase 1` +- Product Type: `Crane` +- Owner flow: MK -> Sales Crane -> Sales Manager -> CEO +- Expected status: manager-approved and waiting final CEO approval + +### Scenario B: Dock Door Project + +- Customer: `Eastern Logistics Co., Ltd.` +- End Customer: `WHA Warehouse` +- Project: `Loading Dock Expansion` +- Product Type: `Dock Door` +- Owner flow: MK -> Sales Dock Door +- Expected status: draft/follow-up style commercial progress before final submission + +### Scenario C: Lost / No Quotation + +- Customer: `Demo Lost Customer` +- Expected status: `No Quotation` +- Expected reason: `Budget not approved` + +### Scenario D: Follow-up Due States + +- seeded at least one follow-up due today +- seeded at least one overdue follow-up +- seeded at least one upcoming follow-up + +## Approval Flow Expectation + +Seeded workflow: `Quotation Standard Approval` + +1. Sales submits quotation +2. Sales Manager approves +3. Department Manager approves +4. CEO final approves + +For UAT simplicity, `mgr.sales.uat@alla.local` holds both manager stages so the queue is easy to validate visually. + +## Seed Commands + +```bash +npm run seed:system +npm run seed:uat +npm run seed:reset -- --allow-db-reset +``` + +## Verification + +```bash +npm run typecheck +npm run build +npm run seed:reset -- --allow-db-reset +npm run verify:encoding +npm run verify:crm-access +npm run audit:pdf +``` diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 1ca7fb0..fc0e837 100644 --- a/package.json +++ b/package.json @@ -12,47 +12,42 @@ "format": "oxfmt --write .", "format:check": "oxfmt --check .", "prepare": "husky", - "gen": "npm run verify:encoding && npx drizzle-kit generate", + "typecheck": "tsc --noEmit", "db:generate": "npx drizzle-kit generate", "db:migrate": "npx drizzle-kit migrate", "db:studio": "npx drizzle-kit studio", - "db:reset": "node --experimental-strip-types scripts/db/reset-database.ts", + "db:reset": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/db/reset-database.ts", "db:setup": "npm run db:migrate && npm run seed:system", "db:setup:uat": "npm run db:setup && npm run seed:uat", - "db:fresh": "npm run db:reset && npm run db:migrate && npm run seed:system", - "db:fresh:uat": "npm run db:fresh && npm run seed:uat", - "migrate": "npm run db:migrate", - "studio": "npm run db:studio", + "db:fresh": "npm run db:reset -- --allow-db-reset && npm run db:migrate && npm run seed:system", + "db:fresh:uat": "npm run db:reset -- --allow-db-reset && npm run db:migrate && npm run seed:system && npm run seed:uat", + "setup:db": "npm run db:setup", "seed:super-admin": "node scripts/seed-super-admin.js", - "seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", - "seed:crm-uat": "node --experimental-strip-types src/db/seeds/crm-uat.seed.ts", - "seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts", - "seed:system": "node --experimental-strip-types src/db/seeds/system.seed.ts", - "seed:uat": "node --experimental-strip-types src/db/seeds/uat.seed.ts", - "seed:pdf-template": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts", + "seed:foundation": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types src/db/seeds/foundation.seed.ts", + "seed:crm-uat": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types src/db/seeds/crm-uat.seed.ts", + "seed:system": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/seed-system.ts", + "seed:uat": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/seed-uat.ts", + "seed:reset": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/seed-reset.ts", + "seed:pdf-template": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/reseed-pdf-template-version.ts", + "seed:pdf-template-version": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/reseed-pdf-template-version.ts", "migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts", "seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js", - "legacy:seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js", "verify:task-h1": "node scripts/verify-task-h1.js", - "verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js", - "legacy:verify:task-h1": "node scripts/verify-task-h1.js", - "legacy:verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js", + "verify:task-h3": "node scripts/verify-task-h3.js", "verify:crm-access": "node --experimental-strip-types scripts/security/verify-crm-access.ts", + "verify:encoding": "node scripts/verify-encoding.mjs", "audit:pdf:inventory": "node --experimental-strip-types scripts/audit-pdf-template-inventory.ts", "audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts", "audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts", "audit:pdf:report": "node --experimental-strip-types scripts/generate-pdf-audit-report.ts", - "verify:encoding": "node scripts/verify-encoding.mjs", "audit:pdf:versions": "node --experimental-strip-types scripts/audit-template-version-integrity.ts", "audit:pdf:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts", "audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts", "audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts", "audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts", - "audit:pdf:visual": "npx tsx scripts/audit-pdf-visual-regression.ts", - "audit:pdf:visual:baseline": "npx tsx scripts/audit-pdf-visual-regression.ts -- --write-baseline", + "audit:pdf:visual": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts", + "audit:pdf:visual:baseline": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts --refresh-baseline", "audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity", - "setup:db": "npm run db:setup", - "pdf:reseed": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts", "pdf:activate:product": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate" }, "dependencies": { diff --git a/plans/task-clean-pk.md b/plans/task-clean-pk.md new file mode 100644 index 0000000..da4a91a --- /dev/null +++ b/plans/task-clean-pk.md @@ -0,0 +1,433 @@ +# Task: Clean package.json Scripts + UAT Seed + Resettable System Seed + +## Role + +You are a Next.js full-stack engineer working on ALLA OS CRM. + +Tech stack: + +* Next.js Full Stack +* TypeScript +* Drizzle ORM +* PostgreSQL +* shadcn/ui +* Auth.js / Keycloak-compatible user model +* pdfme document template system + +## Objective + +Clean unused `package.json` scripts and implement deterministic seed scripts for UAT/demo data that clearly shows the CRM workflow: + +```text +Marketing (MK) -> Sales -> Manager -> CEO +``` + +The system must be resettable to a clean starting state at any time. + +--- + +## Part 1: Audit and Clean package.json + +Inspect `package.json`, `scripts/`, `src/db/seeds/`, `drizzle/`, and existing documentation. + +### Requirements + +1. Identify scripts that are: + + * broken + * duplicated + * unused + * pointing to missing files + * legacy from old architecture + * confusing or overlapping + +2. Do not delete blindly. + + * First create a report file: + +```text +docs/audit/package-scripts-cleanup.md +``` + +3. The report must include: + +```text +Script Name +Current Command +Status: keep / rename / remove / replace +Reason +Replacement Command if any +``` + +4. Then update `package.json`. + +### Recommended final script groups + +```json +{ + "scripts": { + "dev": "...", + "build": "...", + "start": "...", + "lint": "...", + "typecheck": "tsc --noEmit", + + "db:generate": "...", + "db:migrate": "...", + "db:studio": "...", + "db:fresh": "...", + "db:fresh:uat": "...", + + "seed:system": "...", + "seed:uat": "...", + "seed:reset": "...", + "seed:pdf-template": "...", + + "verify:encoding": "...", + "verify:crm-access": "...", + "audit:pdf": "..." + } +} +``` + +Use the actual project commands after inspection. + +--- + +## Part 2: Implement Resettable Seed Architecture + +Create or normalize seed scripts under: + +```text +src/db/seeds/ +scripts/ +``` + +Expected command behavior: + +### 1. System seed + +```bash +npm run seed:system +``` + +Purpose: + +* create minimum required system data +* organizations +* branches +* product types +* roles +* permissions +* master options +* document sequences +* approval definitions +* PDF template records + +Must be idempotent. + +--- + +### 2. UAT seed + +```bash +npm run seed:uat +``` + +Purpose: +Create realistic demo data for UAT so each role can log in and clearly see its own view. + +Seed role flow: + +```text +MK User + creates Lead + +Sales User + receives assigned Lead + works Opportunity + creates Quotation + +Manager User + sees team pipeline + approves quotation step 1 or 2 + +CEO User + sees executive dashboard + final approval +``` + +--- + +### 3. Full reset seed + +```bash +ALLOW_DB_RESET=true npm run seed:reset +``` + +Purpose: +Reset all business/UAT data and rebuild from clean baseline. + +Rules: + +* Must require `ALLOW_DB_RESET=true` +* Must refuse to run without this env flag +* Must never run accidentally in production +* Must clear CRM transactional tables in dependency-safe order +* Must preserve migrations +* Must rebuild system seed +* Must rebuild UAT seed + +Expected flow: + +```text +validate environment +clear transactional CRM data +clear configurable seed-owned master data if needed +run seed:system +run seed:uat +run audit/verification +``` + +--- + +## Part 3: UAT Demo Users + +Create clear users and memberships. + +Example users: + +```text +mk.uat@alla.local +sales.crane.uat@alla.local +sales.dockdoor.uat@alla.local +mgr.sales.uat@alla.local +ceo.uat@alla.local +admin.uat@alla.local +``` + +Each user must have: + +* deterministic id +* name +* email +* role +* businessRole +* permissions +* organization membership +* branch scope +* product type scope where applicable + +Use existing schema and permission model. Do not invent unrelated tables. + +--- + +## Part 4: UAT Demo Data Scenario + +Create data that makes the dashboard and permissions obvious. + +### Scenario A: Crane Project + +```text +Customer: Siam Manufacturing Co., Ltd. +End Customer: Toyota Gateway Plant +Project: Overhead Crane Installation Phase 1 +Product Type: Crane +Lead created by: MK +Assigned to: Sales Crane +Manager: Sales Manager +Final approver: CEO +Quotation status: Pending Approval / Approved +Value: realistic THB amount +``` + +### Scenario B: Dock Door Project + +```text +Customer: Eastern Logistics Co., Ltd. +End Customer: WHA Warehouse +Project: Loading Dock Expansion +Product Type: Dock Door +Lead created by: MK +Assigned to: Sales Dock Door +Quotation status: Draft / Follow-up +``` + +### Scenario C: Lost / No Quotation + +```text +Customer: Demo Lost Customer +Reason: Budget not approved / competitor selected +Status: Closed Lost or No Quotation +``` + +### Scenario D: Follow-up Due + +Create at least: + +* one due today +* one overdue +* one upcoming + +This helps test calendar/dashboard/follow-up UI. + +--- + +## Part 5: Approval Flow Seed + +Create approval definition: + +```text +Quotation Standard Approval +Step 1: Sales Manager +Step 2: Department Manager or Business Manager +Step 3: CEO +``` + +For UAT, make the flow visually simple: + +```text +Sales submits quotation +Manager approves +CEO final approves +``` + +Seed at least: + +* one draft quotation +* one submitted quotation +* one manager-approved quotation waiting for CEO +* one fully approved quotation +* one rejected quotation if supported + +--- + +## Part 6: Document Sequence Seed + +Seed document sequences for all main document types: + +```text +Lead +Opportunity +Quotation +PO if supported +``` + +Code format: + +```text +ProductType + YYMM + running +CR2606-001 +DK2606-001 +SOL2606-001 +SV2606-001 +SP2606-001 +``` + +Branch should represent internal ALLA branch/business unit, not customer branch. + +--- + +## Part 7: PDF Template Seed + +Ensure PDF template seeding is included. + +Runtime must use DB template versions, not raw JSON directly. + +Add or confirm command: + +```bash +npm run seed:pdf-template +``` + +If template reset is needed, add: + +```bash +npm run seed:pdf-template:reset +``` + +Expected behavior: + +* deactivate old active template versions if needed +* insert/update template version from source JSON +* activate the correct version +* run `npm run audit:pdf` + +Do not leave multiple active versions for the same template. + +--- + +## Part 8: Safety Requirements + +1. All seed scripts must be idempotent. +2. Running `npm run seed:uat` twice must not duplicate data. +3. Reset must require explicit env flag. +4. Scripts must log what they changed. +5. Use deterministic IDs or stable unique keys. +6. Do not hide errors with empty catch blocks. +7. Use transactions where practical. +8. Add verification output after seed. + +--- + +## Part 9: Verification Commands + +After implementation, run: + +```bash +npm run typecheck +npm run build +npm run seed:reset +npm run verify:encoding +npm run verify:crm-access +npm run audit:pdf +``` + +If some commands do not exist, either: + +* add them properly, or +* document why they are not available. + +--- + +## Deliverables + +Create or update: + +```text +package.json +docs/audit/package-scripts-cleanup.md +docs/seeding/uat-seed-guide.md +scripts/seed-system.ts +scripts/seed-uat.ts +scripts/seed-reset.ts +scripts/reseed-pdf-template-version.ts if needed +src/db/seeds/* +``` + +The UAT seed guide must include: + +```text +User +Email +Role +What this user should see +Expected CRM data +Expected approval actions +``` + +--- + +## Acceptance Criteria + +* `package.json` has no dead scripts. +* `npm run seed:system` works. +* `npm run seed:uat` works. +* `ALLOW_DB_RESET=true npm run seed:reset` resets and reseeds cleanly. +* UAT data clearly demonstrates MK -> Sales -> Manager -> CEO. +* Each role sees different data according to permission. +* Quotation approval flow has testable documents. +* PDF template audit passes. +* No duplicate active PDF template version exists. +* TypeScript passes. +* Build passes. diff --git a/scripts/db/reset-database.ts b/scripts/db/reset-database.ts index 8f996de..e0b6569 100644 --- a/scripts/db/reset-database.ts +++ b/scripts/db/reset-database.ts @@ -42,11 +42,21 @@ function loadLocalEnv() { loadEnvFile(path.resolve(process.cwd(), '.env')); } +const ALLOW_DB_RESET_FLAG = '--allow-db-reset'; + function assertSafeToReset(databaseUrl: string) { - if (process.env.ALLOW_DB_RESET !== 'true') { - throw new Error('ALLOW_DB_RESET=true is required.'); + const allowDbReset = + process.env.ALLOW_DB_RESET === 'true' || + process.argv.includes(ALLOW_DB_RESET_FLAG); + + if (!allowDbReset) { + throw new Error( + `Database reset requires explicit confirmation. Run "npm run db:reset -- ${ALLOW_DB_RESET_FLAG}".` + ); } + process.env.ALLOW_DB_RESET = 'true'; + if ((process.env.NODE_ENV ?? '').toLowerCase() === 'production') { throw new Error('Database reset is blocked in NODE_ENV=production.'); } diff --git a/scripts/lib/seed-utils.ts b/scripts/lib/seed-utils.ts new file mode 100644 index 0000000..58b1eec --- /dev/null +++ b/scripts/lib/seed-utils.ts @@ -0,0 +1,110 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import postgres from 'postgres'; + +export type SqlClient = ReturnType; + +function loadEnvFile(filePath: string) { + if (!fs.existsSync(filePath)) { + return; + } + + const content = fs.readFileSync(filePath, 'utf8'); + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + + if (!line || line.startsWith('#')) { + continue; + } + + const separatorIndex = line.indexOf('='); + + if (separatorIndex === -1) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (!(key in process.env)) { + process.env[key] = value; + } + } +} + +export function loadLocalEnv() { + loadEnvFile(path.resolve(process.cwd(), '.env.local')); + loadEnvFile(path.resolve(process.cwd(), '.env')); +} + +export function requireEnv(name: string): string { + const value = process.env[name]?.trim(); + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +export function createSqlClient(): SqlClient { + loadLocalEnv(); + return postgres(requireEnv('DATABASE_URL'), { prepare: false }); +} + +export function deterministicId(input: string): string { + const digest = createHash('sha256').update(input).digest('hex'); + const part3 = `5${digest.slice(13, 16)}`; + const part4 = `a${digest.slice(17, 20)}`; + + return [ + digest.slice(0, 8), + digest.slice(8, 12), + part3, + part4, + digest.slice(20, 32) + ].join('-'); +} + +export const NODE_TS_RUNTIME_ARGS = [ + '--disable-warning=ExperimentalWarning', + '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON', + '--experimental-strip-types' +]; + +export function runNodeScript(label: string, scriptPath: string, args: string[] = []) { + const result = spawnSync( + process.execPath, + [...NODE_TS_RUNTIME_ARGS, scriptPath, ...args], + { + stdio: 'inherit', + env: process.env + } + ); + + if (result.status !== 0) { + throw new Error(`[${label}] failed with exit code ${result.status ?? 1}`); + } +} + +export function runCommand(label: string, command: string, args: string[] = []) { + const result = spawnSync(command, args, { + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32' + }); + + if (result.status !== 0) { + throw new Error(`[${label}] failed with exit code ${result.status ?? 1}`); + } +} diff --git a/scripts/seed-reset.ts b/scripts/seed-reset.ts new file mode 100644 index 0000000..9678c35 --- /dev/null +++ b/scripts/seed-reset.ts @@ -0,0 +1,25 @@ +import { runCommand, runNodeScript } from './lib/seed-utils.ts'; + +const ALLOW_DB_RESET_FLAG = '--allow-db-reset'; + +function main() { + const allowDbReset = + process.env.ALLOW_DB_RESET === 'true' || + process.argv.includes(ALLOW_DB_RESET_FLAG); + + if (!allowDbReset) { + throw new Error( + `seed:reset requires explicit confirmation. Run "npm run seed:reset -- ${ALLOW_DB_RESET_FLAG}".` + ); + } + + // Normalize the guard so downstream reset scripts keep the same safety contract. + process.env.ALLOW_DB_RESET = 'true'; + + runNodeScript('seed:reset:db-reset', 'scripts/db/reset-database.ts'); + runCommand('seed:reset:db-migrate', 'npx', ['drizzle-kit', 'migrate']); + runNodeScript('seed:reset:seed-system', 'scripts/seed-system.ts'); + runNodeScript('seed:reset:seed-uat', 'scripts/seed-uat.ts'); +} + +main(); diff --git a/scripts/seed-system.ts b/scripts/seed-system.ts new file mode 100644 index 0000000..ef0c651 --- /dev/null +++ b/scripts/seed-system.ts @@ -0,0 +1,7 @@ +import { runNodeScript } from './lib/seed-utils.ts'; + +function main() { + runNodeScript('seed:system:core', 'src/db/seeds/system.seed.ts'); +} + +main(); diff --git a/scripts/seed-uat.ts b/scripts/seed-uat.ts new file mode 100644 index 0000000..b2c54ce --- /dev/null +++ b/scripts/seed-uat.ts @@ -0,0 +1,7 @@ +import { runNodeScript } from './lib/seed-utils.ts'; + +function main() { + runNodeScript('seed:uat:core', 'src/db/seeds/uat.seed.ts'); +} + +main(); diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 07a8b6a..c2a4a70 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -1,5 +1,9 @@ -'use client'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +"use client"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { DropdownMenu, DropdownMenuContent, @@ -7,8 +11,8 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu'; + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Sidebar, SidebarContent, @@ -22,18 +26,18 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, - SidebarRail -} from '@/components/ui/sidebar'; -import { UserAvatarProfile } from '@/components/user-avatar-profile'; -import { navGroups } from '@/config/nav-config'; -import { useMediaQuery } from '@/hooks/use-media-query'; -import { useFilteredNavGroups } from '@/hooks/use-nav'; -import Link from 'next/link'; -import { signOut, useSession } from 'next-auth/react'; -import { usePathname, useRouter } from 'next/navigation'; -import * as React from 'react'; -import { Icons } from '../icons'; -import { OrgSwitcher } from '../org-switcher'; + SidebarRail, +} from "@/components/ui/sidebar"; +import { UserAvatarProfile } from "@/components/user-avatar-profile"; +import { navGroups } from "@/config/nav-config"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { useFilteredNavGroups } from "@/hooks/use-nav"; +import Link from "next/link"; +import { signOut, useSession } from "next-auth/react"; +import { usePathname, useRouter } from "next/navigation"; +import * as React from "react"; +import { Icons } from "../icons"; +import { OrgSwitcher } from "../org-switcher"; export default function AppSidebar() { const pathname = usePathname(); @@ -44,59 +48,67 @@ export default function AppSidebar() { const user = session?.user ?? null; const organization = session?.user?.activeOrganizationId; const canManageOrganization = - session?.user?.systemRole === 'super_admin' || - (!!organization && session?.user?.activeMembershipRole === 'admin'); + session?.user?.systemRole === "super_admin" || + (!!organization && session?.user?.activeMembershipRole === "admin"); React.useEffect(() => { // Side effects based on sidebar state changes }, [isOpen]); React.useEffect(() => { - if (process.env.NODE_ENV !== 'development') { + if (process.env.NODE_ENV !== "development") { return; } - console.info('[sidebar-debug]', { + console.info("[sidebar-debug]", { systemRole: session?.user?.systemRole ?? null, activeOrganizationId: session?.user?.activeOrganizationId ?? null, activeMembershipRole: session?.user?.activeMembershipRole ?? null, activePermissions: session?.user?.activePermissions ?? [], - visibleItems: filteredGroups.flatMap((group) => group.items.map((item) => item.title)) + visibleItems: filteredGroups.flatMap((group) => + group.items.map((item) => item.title), + ), }); }, [ filteredGroups, session?.user?.activeOrganizationId, session?.user?.activePermissions, session?.user?.activeMembershipRole, - session?.user?.systemRole + session?.user?.systemRole, ]); return ( - - + + - + {filteredGroups.map((group) => ( - - {group.label && {group.label}} + + {group.label && ( + {group.label} + )} {group.items.map((item) => { const Icon = item.icon ? Icons[item.icon] : Icons.logo; - const itemKey = item.url || `${group.label ?? 'group'}-${item.title}`; + const itemKey = + item.url || `${group.label ?? "group"}-${item.title}`; return item?.items && item?.items?.length > 0 ? ( - + {item.icon && } {item.title} - + @@ -105,7 +117,10 @@ export default function AppSidebar() { - + {subItem.title} @@ -141,49 +156,57 @@ export default function AppSidebar() { {user && ( - + )} - + - -
+ +
{user && ( - + )}
- router.push('/dashboard/profile')}> - + router.push("/dashboard/profile")} + > + Profile - {canManageOrganization && ( - router.push('/dashboard/billing')}> - - Billing - - )} - router.push('/dashboard/notifications')}> - + router.push("/dashboard/notifications")} + > + Notifications - void signOut({ redirectTo: '/auth/sign-in' })}> - + void signOut({ redirectTo: "/auth/sign-in" })} + > + Sign out diff --git a/src/components/layout/user-nav.tsx b/src/components/layout/user-nav.tsx index ade2e1e..e9e9c64 100644 --- a/src/components/layout/user-nav.tsx +++ b/src/components/layout/user-nav.tsx @@ -1,5 +1,5 @@ -'use client'; -import { Button } from '@/components/ui/button'; +"use client"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -7,11 +7,11 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu'; -import { UserAvatarProfile } from '@/components/user-avatar-profile'; -import { signOut, useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { UserAvatarProfile } from "@/components/user-avatar-profile"; +import { signOut, useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; export function UserNav() { const { data: session } = useSession(); const user = session?.user; @@ -21,28 +21,37 @@ export function UserNav() { return ( - - - -
-

{user.name}

-

{user.email}

+ + +
+

{user.name}

+

+ {user.email} +

- router.push('/dashboard/profile')}> + router.push("/dashboard/profile")}> Profile - Billing + Settings New Team - void signOut({ redirectTo: '/auth/sign-in' })}> + void signOut({ redirectTo: "/auth/sign-in" })} + > Sign out
diff --git a/src/db/seeds/crm-uat.seed.ts b/src/db/seeds/crm-uat.seed.ts index 78f1291..fc26aa8 100644 --- a/src/db/seeds/crm-uat.seed.ts +++ b/src/db/seeds/crm-uat.seed.ts @@ -345,6 +345,7 @@ type UserContext = { admin: UserMembershipRow; marketing: UserMembershipRow; sales: UserMembershipRow[]; + salesByProductType: Partial>; salesManager: UserMembershipRow; departmentManager: UserMembershipRow; topManager: UserMembershipRow; @@ -352,33 +353,41 @@ type UserContext = { const SEED_TAG = "crm-uat-seed-v1"; const PERIOD = "2606"; +const UAT_EMAILS = { + marketing: "mk.uat@alla.local", + salesCrane: "sales.crane.uat@alla.local", + salesDockDoor: "sales.dockdoor.uat@alla.local", + salesManager: "mgr.sales.uat@alla.local", + topManager: "ceo.uat@alla.local", + admin: "admin.uat@alla.local", +} as const; const STORIES: StoryConfig[] = [ { slug: "website-warehouse-crane-win", - title: "Lead received from website and won after approval", + title: "Siam Manufacturing crane project waiting final CEO approval", customerName: "บริษัท บางกอก ฟู้ด อินดัสทรี จำกัด", - province: "Bangkok", - district: "Min Buri", + province: "Chachoengsao", + district: "Bang Pakong", branchCode: "head_office", customerGroupCode: "industrial", customerSubGroupCode: "factory", productTypeCode: "crane", quotationTypeCode: "crane", priorityCode: "high", - leadChannelCode: "website", + leadChannelCode: "referral", awarenessCode: "google_search_website", leadStatusCode: "assigned", - leadFollowupStatusCode: "waiting_po", + leadFollowupStatusCode: "drawing_followup", opportunityStatusCode: "negotiation", - chancePercent: 80, - estimatedValue: 12400000, - projectName: "Warehouse Crane Installation Phase 2", - projectLocation: "Bangkok Food Industrial Estate, Min Buri", + chancePercent: 78, + estimatedValue: 8750000, + projectName: "Overhead Crane Installation Phase 1", + projectLocation: "Toyota Gateway Plant", leadDescription: - "Customer asked for top-running overhead crane to support raw material warehouse expansion.", - quotePlan: "accepted", - opportunityOutcome: "won", + "Marketing qualified the crane project and handed it to the crane sales queue for quotation approval.", + quotePlan: "pending_step_2", + opportunityOutcome: "open", multiParties: [ { roleCode: "contractor", @@ -392,57 +401,57 @@ const STORIES: StoryConfig[] = [ }, { slug: "facebook-lost-price", - title: "Lead lost to competitor because price too high", + title: "Demo lost customer with no quotation because budget not approved", customerName: "บริษัท ไทย ออโต้ เพรส จำกัด", - province: "Samut Prakan", - district: "Bang Phli", - branchCode: "factory", + province: "Bangkok", + district: "Lat Krabang", + branchCode: "service", customerGroupCode: "industrial", - customerSubGroupCode: "factory", - productTypeCode: "crane", - quotationTypeCode: "crane", + customerSubGroupCode: "warehouse", + productTypeCode: "dockdoor", + quotationTypeCode: "dockdoor", priorityCode: "normal", - leadChannelCode: "facebook", - awarenessCode: "social_media", - leadStatusCode: "closed_lost", - leadFollowupStatusCode: "quotation_preparation", - opportunityStatusCode: "quotation_created", - chancePercent: 45, - estimatedValue: 5800000, - projectName: "Stamping Line Hoist Replacement", - projectLocation: "Bang Phli Industrial Estate", + leadChannelCode: "website", + awarenessCode: "google_search_website", + leadStatusCode: "cancel", + leadFollowupStatusCode: "budget_pending", + opportunityStatusCode: "qualification", + chancePercent: 20, + estimatedValue: 1900000, + projectName: "Budget Hold Demo Project", + projectLocation: "Lat Krabang Logistics Zone", leadDescription: - "Customer compared 5 ton monorail hoist replacement between ALLA and Konecranes.", - competitor: "Konecranes", - quotePlan: "sent_follow_up", - opportunityOutcome: "lost", - lostReasonCode: "price_too_high", + "Opportunity was stopped before quotation because customer budget was not approved and competitor was still under consideration.", + competitor: "Competitor Selected", + quotePlan: "cancelled", + opportunityOutcome: "no_quotation", + noQuotationReasonCode: "budget_not_ready", }, { slug: "revision-r02-approved", - title: "Customer requested revision and approved revision R02", + title: "Eastern Logistics dock door project in draft and follow-up stage", customerName: "บริษัท กรีน โลจิสติกส์ พาร์ค จำกัด", - province: "Pathum Thani", - district: "Khlong Luang", + province: "Chonburi", + district: "Si Racha", branchCode: "service", customerGroupCode: "industrial", - customerSubGroupCode: "logistics", + customerSubGroupCode: "warehouse", productTypeCode: "dockdoor", quotationTypeCode: "dockdoor", priorityCode: "high", leadChannelCode: "referral", awarenessCode: "word_of_mouth", leadStatusCode: "assigned", - leadFollowupStatusCode: "waiting_po", - opportunityStatusCode: "negotiation", - chancePercent: 70, - estimatedValue: 9300000, - projectName: "Cold Chain Dock Leveller Upgrade", - projectLocation: "Khlong Luang Logistics Campus", + leadFollowupStatusCode: "quotation_preparation", + opportunityStatusCode: "proposal_preparation", + chancePercent: 61, + estimatedValue: 4600000, + projectName: "Loading Dock Expansion", + projectLocation: "WHA Warehouse", leadDescription: - "Customer requested revision after consultant changed dock layout and bumper specification.", - quotePlan: "rejected_then_revised", - opportunityOutcome: "won", + "Dock door salesperson is still following up layout confirmation before final quotation submission.", + quotePlan: "sent_follow_up", + opportunityOutcome: "open", multiParties: [ { roleCode: "consultant", @@ -882,6 +891,30 @@ function buildDocumentCode(prefix: string, number: number) { return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`; } +function buildQuotationCode(productTypeCode: string, number: number) { + const prefixByProductType: Record = { + crane: "CR", + dockdoor: "DK", + solarcell: "SOL", + service: "SV", + spare_part: "SP", + }; + + return buildDocumentCode(prefixByProductType[productTypeCode] ?? "QT", number); +} + +function pickSalesUser( + users: UserContext, + productTypeCode: string | undefined, + index: number +) { + if (productTypeCode && users.salesByProductType[productTypeCode]) { + return users.salesByProductType[productTypeCode]!; + } + + return choose(users.sales, index); +} + function seededNote(scope: string) { return `[${SEED_TAG}:${scope}]`; } @@ -1027,21 +1060,48 @@ function buildUserContext(rows: UserMembershipRow[]): UserContext { } const first = rows[0]; - const byRole = (role: string) => - rows.find((row) => row.businessRole === role) ?? null; - const salesRows = rows.filter((row) => row.businessRole === "sales"); + const byRole = (role: string) => rows.find((row) => row.businessRole === role) ?? null; + const byEmail = (email: string) => + rows.find((row) => row.email.toLowerCase() === email.toLowerCase()) ?? null; + const fallbackSalesRows = rows.filter((row) => row.businessRole === "sales"); + const preferredSalesRows = [ + byEmail(UAT_EMAILS.salesCrane), + byEmail(UAT_EMAILS.salesDockDoor), + ].filter((row): row is UserMembershipRow => row !== null); + const salesRows = preferredSalesRows.length + ? preferredSalesRows + : fallbackSalesRows.length + ? fallbackSalesRows + : [rows.find((row) => row.membershipRole === "admin") ?? first]; const admin = + byEmail(UAT_EMAILS.admin) ?? rows.find((row) => row.membershipRole === "admin") ?? rows.find((row) => row.systemRole === "super_admin") ?? first; return { admin, - marketing: byRole("sales_support") ?? admin, - sales: salesRows.length ? salesRows : [admin], - salesManager: byRole("sales_manager") ?? admin, - departmentManager: byRole("department_manager") ?? admin, - topManager: byRole("top_manager") ?? admin, + marketing: + byEmail(UAT_EMAILS.marketing) ?? + byRole("marketing") ?? + byRole("sales_support") ?? + admin, + sales: salesRows, + salesByProductType: { + crane: byEmail(UAT_EMAILS.salesCrane) ?? preferredSalesRows[0] ?? salesRows[0], + dockdoor: + byEmail(UAT_EMAILS.salesDockDoor) ?? + preferredSalesRows.at(-1) ?? + salesRows.at(-1) ?? + salesRows[0], + }, + salesManager: byEmail(UAT_EMAILS.salesManager) ?? byRole("sales_manager") ?? admin, + departmentManager: + byEmail(UAT_EMAILS.salesManager) ?? + byRole("department_manager") ?? + byRole("sales_manager") ?? + admin, + topManager: byEmail(UAT_EMAILS.topManager) ?? byRole("top_manager") ?? admin, }; } @@ -1395,7 +1455,7 @@ function buildLeadAndOpportunityData( } const contact = primaryContactByCustomerId.get(customer.id) ?? null; const createdAt = daysAgo(88 - storyIndex * 4); - const assignedUser = choose(users.sales, storyIndex); + const assignedUser = pickSalesUser(users, story.productTypeCode, storyIndex); const assignedAt = addDays(createdAt, 4); const statusId = getOptionId( optionMap, @@ -2004,7 +2064,7 @@ function buildQuotationData( const customer = storyCustomerMap.get(story.slug); if (!opportunity || !customer) continue; const contact = primaryContactByCustomerId.get(customer.id) ?? null; - const salesperson = choose(users.sales, storyIndex); + const salesperson = pickSalesUser(users, story.productTypeCode, storyIndex); const baseDate = addDays(opportunity.createdAt, 10); const planRevisionCount = story.quotePlan === "returned_then_revised" @@ -2067,7 +2127,7 @@ function buildQuotationData( quotations.push({ id: quotationId, - code: buildDocumentCode("QT", quotationSequence++), + code: buildQuotationCode(story.productTypeCode, quotationSequence++), opportunityId: opportunity.id, customerId: customer.id, contactId: contact?.id ?? null, @@ -2405,7 +2465,10 @@ function buildQuotationData( const quotationId = crypto.randomUUID(); quotations.push({ id: quotationId, - code: buildDocumentCode("QT", quotationSequence++), + code: buildQuotationCode( + choose(["crane", "dockdoor", "solarcell"], quotations.length), + quotationSequence++ + ), opportunityId: null, customerId: customer.id, contactId: contact?.id ?? null, diff --git a/src/db/seeds/system-organization.seed.ts b/src/db/seeds/system-organization.seed.ts new file mode 100644 index 0000000..30322be --- /dev/null +++ b/src/db/seeds/system-organization.seed.ts @@ -0,0 +1,129 @@ +import { getDefaultPermissions } from '../../lib/auth/rbac.ts'; +import { createSqlClient, deterministicId, type SqlClient } from '../../../scripts/lib/seed-utils.ts'; + +type UserRow = { + id: string; + email: string; +}; + +const DEFAULT_ORGANIZATION = { + id: deterministicId('organization:alla-demo'), + name: 'ALLA Demo Organization', + slug: 'alla-demo', + membershipId: deterministicId('membership:alla-demo:super-admin') +}; + +async function resolveSuperAdmin(sql: SqlClient): Promise { + const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase(); + + if (!email) { + throw new Error('SUPER_ADMIN_EMAIL is required before running system organization seed.'); + } + + const [user] = await sql` + select id, email + from users + where email = ${email} + limit 1 + `; + + if (!user) { + throw new Error('Super admin user not found. Run seed-super-admin first.'); + } + + return user; +} + +async function ensureOrganization(sql: SqlClient, superAdmin: UserRow) { + await sql` + insert into organizations ( + id, + name, + slug, + plan, + created_by + ) values ( + ${DEFAULT_ORGANIZATION.id}, + ${DEFAULT_ORGANIZATION.name}, + ${DEFAULT_ORGANIZATION.slug}, + ${'enterprise'}, + ${superAdmin.id} + ) + on conflict (slug) do update + set + name = excluded.name, + plan = excluded.plan, + updated_at = now() + `; + + const membershipPermissions = getDefaultPermissions('admin', 'crm_admin'); + const [existingMembership] = await sql<{ id: string }[]>` + select id + from memberships + where user_id = ${superAdmin.id} + and organization_id = ${DEFAULT_ORGANIZATION.id} + limit 1 + `; + + if (existingMembership) { + await sql` + update memberships + set + role = ${'admin'}, + business_role = ${'crm_admin'}, + permissions = ${sql.array(membershipPermissions)}, + updated_at = now() + where id = ${existingMembership.id} + `; + } else { + await sql` + insert into memberships ( + id, + user_id, + organization_id, + role, + business_role, + permissions, + branch_scope_ids, + product_type_scope_ids + ) values ( + ${DEFAULT_ORGANIZATION.membershipId}, + ${superAdmin.id}, + ${DEFAULT_ORGANIZATION.id}, + ${'admin'}, + ${'crm_admin'}, + ${sql.array(membershipPermissions)}, + ${sql.array([])}, + ${sql.array([])} + ) + `; + } + + await sql` + update users + set + active_organization_id = ${DEFAULT_ORGANIZATION.id}, + updated_at = now() + where id = ${superAdmin.id} + `; +} + +async function main() { + const sql = createSqlClient(); + + try { + const superAdmin = await resolveSuperAdmin(sql); + await ensureOrganization(sql, superAdmin); + console.log(`[seed:system-org] Ready ${DEFAULT_ORGANIZATION.name}`); + } finally { + await sql.end(); + } +} + +main().catch((error) => { + console.error( + '[seed:system-org] Failed:', + error instanceof Error ? error.message : String(error) + ); + process.exitCode = 1; +}); diff --git a/src/db/seeds/system.seed.ts b/src/db/seeds/system.seed.ts index f5ddde0..228785e 100644 --- a/src/db/seeds/system.seed.ts +++ b/src/db/seeds/system.seed.ts @@ -1,5 +1,11 @@ import { spawnSync } from 'node:child_process'; +const NODE_TS_RUNTIME_ARGS = [ + '--disable-warning=ExperimentalWarning', + '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON', + '--experimental-strip-types' +]; + function run(label: string, args: string[]) { const result = spawnSync(process.execPath, args, { stdio: 'inherit', @@ -13,7 +19,12 @@ function run(label: string, args: string[]) { function main() { run('super admin seed', ['scripts/seed-super-admin.js']); - run('foundation seed', ['--experimental-strip-types', 'src/db/seeds/foundation.seed.ts']); + run('system organization seed', [ + ...NODE_TS_RUNTIME_ARGS, + 'src/db/seeds/system-organization.seed.ts' + ]); + run('foundation seed', [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/foundation.seed.ts']); + run('pdf template seed', [...NODE_TS_RUNTIME_ARGS, 'scripts/reseed-pdf-template-version.ts']); } main(); diff --git a/src/db/seeds/uat-users.seed.ts b/src/db/seeds/uat-users.seed.ts new file mode 100644 index 0000000..6e3bb46 --- /dev/null +++ b/src/db/seeds/uat-users.seed.ts @@ -0,0 +1,431 @@ +import bcrypt from 'bcryptjs'; +import { getDefaultPermissions, getDefaultRoleProfiles } from '../../lib/auth/rbac.ts'; +import { createSqlClient, deterministicId, type SqlClient } from '../../../scripts/lib/seed-utils.ts'; + +type OrganizationRow = { + id: string; + name: string; + createdBy: string; +}; + +type OptionRow = { + id: string; + category: string; + code: string; +}; + +type RoleProfileRow = { + id: string; + code: string; +}; + +type UatRoleAssignmentSeed = { + roleCode: string; + isPrimary: boolean; + branchScopeMode: 'all' | 'selected' | 'inherit'; + branchCodes?: string[]; + productTypeScopeMode: 'all' | 'selected' | 'inherit'; + productTypeCodes?: string[]; +}; + +type UatUserSeed = { + key: string; + name: string; + email: string; + password: string; + systemRole: 'super_admin' | 'user'; + membershipRole: 'admin' | 'user'; + businessRole: + | 'marketing' + | 'sales' + | 'sales_support' + | 'sales_manager' + | 'department_manager' + | 'top_manager' + | 'crm_admin'; + roleAssignments: UatRoleAssignmentSeed[]; +}; + +const UAT_PASSWORD = 'UatDemo123!'; + +const UAT_USERS: UatUserSeed[] = [ + { + key: 'mk', + name: 'MK UAT', + email: 'mk.uat@alla.local', + password: UAT_PASSWORD, + systemRole: 'user', + membershipRole: 'user', + businessRole: 'marketing', + roleAssignments: [ + { + roleCode: 'marketing', + isPrimary: true, + branchScopeMode: 'all', + productTypeScopeMode: 'all' + } + ] + }, + { + key: 'sales-crane', + name: 'Sales Crane UAT', + email: 'sales.crane.uat@alla.local', + password: UAT_PASSWORD, + systemRole: 'user', + membershipRole: 'user', + businessRole: 'sales', + roleAssignments: [ + { + roleCode: 'sales', + isPrimary: true, + branchScopeMode: 'all', + productTypeScopeMode: 'selected', + productTypeCodes: ['crane'] + } + ] + }, + { + key: 'sales-dockdoor', + name: 'Sales Dock Door UAT', + email: 'sales.dockdoor.uat@alla.local', + password: UAT_PASSWORD, + systemRole: 'user', + membershipRole: 'user', + businessRole: 'sales', + roleAssignments: [ + { + roleCode: 'sales', + isPrimary: true, + branchScopeMode: 'all', + productTypeScopeMode: 'selected', + productTypeCodes: ['dockdoor'] + } + ] + }, + { + key: 'mgr-sales', + name: 'Sales Manager UAT', + email: 'mgr.sales.uat@alla.local', + password: UAT_PASSWORD, + systemRole: 'user', + membershipRole: 'user', + businessRole: 'sales_manager', + roleAssignments: [ + { + roleCode: 'sales_manager', + isPrimary: true, + branchScopeMode: 'all', + productTypeScopeMode: 'all' + }, + { + roleCode: 'department_manager', + isPrimary: false, + branchScopeMode: 'all', + productTypeScopeMode: 'all' + } + ] + }, + { + key: 'ceo', + name: 'CEO UAT', + email: 'ceo.uat@alla.local', + password: UAT_PASSWORD, + systemRole: 'user', + membershipRole: 'user', + businessRole: 'top_manager', + roleAssignments: [ + { + roleCode: 'top_manager', + isPrimary: true, + branchScopeMode: 'all', + productTypeScopeMode: 'all' + } + ] + }, + { + key: 'admin', + name: 'Admin UAT', + email: 'admin.uat@alla.local', + password: UAT_PASSWORD, + systemRole: 'user', + membershipRole: 'admin', + businessRole: 'crm_admin', + roleAssignments: [ + { + roleCode: 'crm_admin', + isPrimary: true, + branchScopeMode: 'all', + productTypeScopeMode: 'all' + } + ] + } +]; + +async function ensureRoleProfiles( + sql: SqlClient, + organization: OrganizationRow +): Promise> { + const defaultProfiles = getDefaultRoleProfiles(); + + for (const profile of defaultProfiles) { + await sql` + insert into crm_role_profiles ( + id, + organization_id, + code, + name, + description, + permissions, + branch_scope_mode, + product_scope_mode, + ownership_scope, + approval_authority, + is_system, + is_active, + created_by, + updated_by + ) values ( + ${deterministicId(`crm-role-profile:${organization.id}:${profile.code}`)}, + ${organization.id}, + ${profile.code}, + ${profile.name}, + ${profile.description}, + ${sql.array(profile.permissions)}, + ${profile.branchScopeMode}, + ${profile.productScopeMode}, + ${profile.ownershipScope}, + ${profile.approvalAuthority}, + ${profile.isSystem}, + ${true}, + ${organization.createdBy}, + ${organization.createdBy} + ) + on conflict (organization_id, code) do update + set + name = excluded.name, + description = excluded.description, + permissions = excluded.permissions, + branch_scope_mode = excluded.branch_scope_mode, + product_scope_mode = excluded.product_scope_mode, + ownership_scope = excluded.ownership_scope, + approval_authority = excluded.approval_authority, + is_system = excluded.is_system, + is_active = excluded.is_active, + updated_by = excluded.updated_by, + deleted_at = null, + updated_at = now() + `; + } + + const rows = await sql` + select id, code + from crm_role_profiles + where organization_id = ${organization.id} + and deleted_at is null + `; + + return new Map(rows.map((row) => [row.code, row])); +} + +async function loadOptions(sql: SqlClient, organizationId: string): Promise> { + const rows = await sql` + select id, category, code + from ms_options + where organization_id = ${organizationId} + and deleted_at is null + and is_active = true + `; + + return new Map(rows.map((row) => [`${row.category}:${row.code}`, row])); +} + +function resolveScopeIds( + optionMap: Map, + category: string, + codes: string[] | undefined +): string[] { + if (!codes?.length) { + return []; + } + + return codes.map((code) => { + const row = optionMap.get(`${category}:${code}`); + + if (!row) { + throw new Error(`Missing ${category} option for code ${code}`); + } + + return row.id; + }); +} + +async function upsertUatUser( + sql: SqlClient, + organization: OrganizationRow, + optionMap: Map, + roleProfileMap: Map, + seed: UatUserSeed +) { + const passwordHash = await bcrypt.hash(seed.password, 10); + const [userRow] = await sql<{ id: string }[]>` + insert into users ( + id, + name, + email, + password_hash, + system_role, + active_organization_id + ) values ( + ${deterministicId(`uat-user:${seed.email}`)}, + ${seed.name}, + ${seed.email}, + ${passwordHash}, + ${seed.systemRole}, + ${organization.id} + ) + on conflict (email) do update + set + name = excluded.name, + password_hash = excluded.password_hash, + system_role = excluded.system_role, + active_organization_id = excluded.active_organization_id, + updated_at = now() + returning id + `; + + const membershipPermissions = getDefaultPermissions(seed.membershipRole, seed.businessRole); + const existingMembership = + ( + await sql<{ id: string }[]>` + select id + from memberships + where user_id = ${userRow.id} + and organization_id = ${organization.id} + limit 1 + ` + )[0] ?? null; + + if (existingMembership) { + await sql` + update memberships + set + role = ${seed.membershipRole}, + business_role = ${seed.businessRole}, + permissions = ${sql.array(membershipPermissions)}, + branch_scope_ids = ${sql.array([])}, + product_type_scope_ids = ${sql.array([])}, + updated_at = now() + where id = ${existingMembership.id} + `; + } else { + await sql` + insert into memberships ( + id, + user_id, + organization_id, + role, + business_role, + permissions, + branch_scope_ids, + product_type_scope_ids + ) values ( + ${deterministicId(`membership:${organization.id}:${seed.email}`)}, + ${userRow.id}, + ${organization.id}, + ${seed.membershipRole}, + ${seed.businessRole}, + ${sql.array(membershipPermissions)}, + ${sql.array([])}, + ${sql.array([])} + ) + `; + } + + await sql` + delete from crm_user_role_assignments + where organization_id = ${organization.id} + and user_id = ${userRow.id} + `; + + for (const assignment of seed.roleAssignments) { + const roleProfile = roleProfileMap.get(assignment.roleCode); + + if (!roleProfile) { + throw new Error(`Missing CRM role profile ${assignment.roleCode}`); + } + + await sql` + insert into crm_user_role_assignments ( + id, + organization_id, + user_id, + role_profile_id, + branch_scope_mode, + branch_scope_ids, + product_type_scope_mode, + product_type_scope_ids, + is_primary, + is_active, + assigned_by, + assigned_at, + created_at, + updated_at, + deleted_at + ) values ( + ${deterministicId(`crm-role-assignment:${organization.id}:${seed.email}:${assignment.roleCode}`)}, + ${organization.id}, + ${userRow.id}, + ${roleProfile.id}, + ${assignment.branchScopeMode}, + ${sql.array(resolveScopeIds(optionMap, 'crm_branch', assignment.branchCodes))}, + ${assignment.productTypeScopeMode}, + ${sql.array(resolveScopeIds(optionMap, 'crm_product_type', assignment.productTypeCodes))}, + ${assignment.isPrimary}, + ${true}, + ${organization.createdBy}, + ${new Date()}, + ${new Date()}, + ${new Date()}, + ${null} + ) + `; + } +} + +async function seedOrganization(sql: SqlClient, organization: OrganizationRow) { + const optionMap = await loadOptions(sql, organization.id); + const roleProfileMap = await ensureRoleProfiles(sql, organization); + + for (const seed of UAT_USERS) { + await upsertUatUser(sql, organization, optionMap, roleProfileMap, seed); + } + + console.log(`[seed:uat-users] ${organization.name}: ${UAT_USERS.length} UAT users ready`); +} + +async function main() { + const sql = createSqlClient(); + + try { + const organizations = await sql` + select id, name, created_by as "createdBy" + from organizations + order by name asc + `; + + if (organizations.length === 0) { + throw new Error('No organizations found. Run seed:system first.'); + } + + for (const organization of organizations) { + await seedOrganization(sql, organization); + } + } finally { + await sql.end(); + } +} + +main().catch((error) => { + console.error('[seed:uat-users] Failed:', error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/src/db/seeds/uat.seed.ts b/src/db/seeds/uat.seed.ts index 1fae074..257f0c3 100644 --- a/src/db/seeds/uat.seed.ts +++ b/src/db/seeds/uat.seed.ts @@ -1,17 +1,32 @@ import { spawnSync } from 'node:child_process'; +const NODE_TS_RUNTIME_ARGS = [ + '--disable-warning=ExperimentalWarning', + '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON', + '--experimental-strip-types' +]; + function main() { - const result = spawnSync( - process.execPath, - ['--experimental-strip-types', 'src/db/seeds/crm-uat.seed.ts'], + const steps: Array<{ label: string; args: string[] }> = [ { + label: 'uat users seed', + args: [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/uat-users.seed.ts'] + }, + { + label: 'crm uat data seed', + args: [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/crm-uat.seed.ts'] + } + ]; + + for (const step of steps) { + const result = spawnSync(process.execPath, step.args, { stdio: 'inherit', env: process.env - } - ); + }); - if (result.status !== 0) { - throw new Error(`[seed:uat] failed with exit code ${result.status ?? 1}`); + if (result.status !== 0) { + throw new Error(`[seed:uat] ${step.label} failed with exit code ${result.status ?? 1}`); + } } } diff --git a/src/features/auth/components/sign-in-view.tsx b/src/features/auth/components/sign-in-view.tsx index 52acc35..04e71f2 100644 --- a/src/features/auth/components/sign-in-view.tsx +++ b/src/features/auth/components/sign-in-view.tsx @@ -18,7 +18,7 @@ const signInSchema = z.object({ export default function SignInViewPage() { const searchParams = useSearchParams(); - const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; + const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard/crm"; const [isPending, startTransition] = useTransition(); const form = useAppForm({ diff --git a/src/features/crm/customers/server/service.ts b/src/features/crm/customers/server/service.ts index 85098c2..4ac691c 100644 --- a/src/features/crm/customers/server/service.ts +++ b/src/features/crm/customers/server/service.ts @@ -610,7 +610,7 @@ async function canAccessCustomerRow( accessContext, )); - if (accessContext.ownershipScope === "monitor") { + if (accessContext.ownershipScope === 'monitor') { return opportunityCustomerIds.has(row.id); } diff --git a/src/proxy.ts b/src/proxy.ts index c69f29e..daaa96b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,29 +1,39 @@ -import { auth } from '@/auth'; -import { NextResponse } from 'next/server'; +import { auth } from "@/auth"; +import { NextResponse } from "next/server"; -const protectedApiPrefixes = ['/api/products', '/api/organizations', '/api/users']; -const superAdminOnlyRoutes = ['/dashboard/workspaces']; -const organizationAdminRoutes = ['/dashboard/users', '/dashboard/workspaces/team']; +const protectedApiPrefixes = [ + "/api/products", + "/api/organizations", + "/api/users", +]; +const superAdminOnlyRoutes = ["/dashboard/workspaces"]; +const organizationAdminRoutes = [ + "/dashboard/users", + "/dashboard/workspaces/team", +]; export default auth((req) => { const pathname = req.nextUrl.pathname; - const isDashboardRoute = pathname.startsWith('/dashboard'); - const isProtectedApiRoute = protectedApiPrefixes.some((prefix) => pathname.startsWith(prefix)); + const isDashboardRoute = pathname.startsWith("/dashboard/crm"); + const isProtectedApiRoute = protectedApiPrefixes.some((prefix) => + pathname.startsWith(prefix), + ); const user = req.auth?.user; const isSignedIn = !!user; - const isSuperAdmin = user?.systemRole === 'super_admin'; + const isSuperAdmin = user?.systemRole === "super_admin"; const isOrganizationAdmin = isSuperAdmin || (user?.activeOrganizationId && - (user.activeMembershipRole === 'admin' || user.activePermissions?.includes('users:manage'))); + (user.activeMembershipRole === "admin" || + user.activePermissions?.includes("users:manage"))); if (!isSignedIn && (isDashboardRoute || isProtectedApiRoute)) { if (isProtectedApiRoute) { - return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); } - const signInUrl = new URL('/auth/sign-in', req.nextUrl.origin); - signInUrl.searchParams.set('callbackUrl', pathname); + const signInUrl = new URL("/auth/sign-in", req.nextUrl.origin); + signInUrl.searchParams.set("callbackUrl", pathname); return NextResponse.redirect(signInUrl); } @@ -32,7 +42,7 @@ export default auth((req) => { superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) && !isSuperAdmin ) { - return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin)); + return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); } if ( @@ -40,7 +50,7 @@ export default auth((req) => { organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) && !isOrganizationAdmin ) { - return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin)); + return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); } return NextResponse.next(); @@ -48,7 +58,7 @@ export default auth((req) => { export const config = { matcher: [ - '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', - '/(api|trpc)(.*)' - ] + "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/(api|trpc)(.*)", + ], };