diff --git a/.gitignore b/.gitignore index 2309cc8..54ebf32 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ web_modules/ # Next.js build output .next out +public/generated/ # Nuxt.js build / generate output .nuxt diff --git a/docs/adr/0009-approved-document-storage-lifecycle.md b/docs/adr/0009-approved-document-storage-lifecycle.md new file mode 100644 index 0000000..942fd11 --- /dev/null +++ b/docs/adr/0009-approved-document-storage-lifecycle.md @@ -0,0 +1,24 @@ +Status: +accepted + +Context: +Task H introduced server-side PDF generation for quotation preview, download, and approved-document persistence. +Task H.1 validated the full path with a real quotation fixture and confirmed that approved artifacts currently persist to local disk under `public/generated/...`. + +Decision: +- Keep approved quotation PDFs on local filesystem storage for development and MVP use. +- Persist the generated file path on the quotation row via `approved_pdf_url`. +- Persist `approved_snapshot` and `approved_template_version_id` alongside the stored file path so the approved artifact can be traced back to the data and template used at generation time. +- Treat this storage model as acceptable only for local/dev and early MVP deployment, not as the final production storage architecture. + +Consequences: +- Preview/download/generate-approved flows can ship now without blocking on object storage integration. +- Approved PDF persistence works with the current dashboard and static file serving model. +- Local public storage is simple, but it does not provide immutability guarantees, signed access control, retention policy, or durable storage semantics expected for long-lived production artifacts. + +Future: +- introduce an object-storage abstraction shared by generated documents and manual attachments +- support signed URLs or another private delivery strategy where required +- define immutable approved artifact rules and whether regeneration is allowed +- add retention/cleanup policy for superseded or regenerated artifacts +- move approved-PDF generation to an async job when post-approval auto-generation is introduced diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index a80299c..a911d72 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -2,234 +2,137 @@ ## After Task D -### 1. Database FK - -Customer / Contact / Enquiry ยัง enforce relationship ด้วย application logic +### Database foreign keys +Customer, contact, and enquiry relationships are still enforced mainly in application logic. Future: +- add FK constraints after the CRM schema settles +- review cascade behavior before enabling hard relational enforcement -* เพิ่ม FK หลัง schema stable - -### 2. Date Input - -Form ยังใช้ text input แบบ YYYY-MM-DD +### Date input UX +Several forms still use plain `YYYY-MM-DD` text/date inputs. Future: +- move to a shared date picker pattern +- standardize timezone handling for CRM forms -* เปลี่ยนเป็น Date Picker กลางของระบบ - -### 3. Customer Related Enquiries Pagination - -หน้า customer detail แสดง related enquiries แล้ว แต่ยังไม่มี pagination +### Related enquiry pagination +Customer detail can show related enquiries, but that view still has no dedicated pagination strategy. Future: +- add paging once real data volume grows -* เพิ่มเมื่อข้อมูลเริ่มเยอะ - -### 4. Follow-up Activity - -Activity timeline ยังพึ่ง audit payload +### Follow-up activity timeline +Activity history still relies heavily on audit payloads instead of a curated domain timeline. Future: - -* ทำ combined activity view/service - ---- +- build a combined activity view/service for CRM interactions ## After Task E -### 5. Mixed Tax Rollup - -Quotation item รองรับ taxRate ต่อรายการแล้ว - -Current: - -* quotation summary ยังใช้ header tax calculation +### Mixed tax rollup +Quotation items support per-line tax input, but summary calculation still assumes a header-centric tax model. Future: +- support mixed-tax aggregation from actual item rows +- align document totals and reporting with per-line tax logic -* รองรับ mixed tax per line -* aggregate tax จาก item จริง - -Priority: - -* Medium - -### 6. Attachment Storage - -Attachment ยังเป็น metadata-only - -Current: - -* เก็บเฉพาะ metadata +### Attachment storage abstraction +Quotation attachments started as metadata-only and approved PDFs now write to local disk, but there is still no real storage abstraction. Future: +- introduce a provider abstraction +- support upload/download lifecycle consistently across manual attachments and generated artifacts +- define migration strategy for existing local/public paths -* storage provider abstraction -* upload service -* download endpoint -* permission check - -Priority: - -* High - -### 7. Revision Snapshot - -Revision copy: - -* header -* items -* customers -* topics - -Current: - -* ไม่ copy follow-ups -* ไม่ copy attachments +### Revision snapshot coverage +Revision copy currently focuses on quotation header, items, customers, and topics. Future: +- decide whether follow-ups and attachments belong in revision snapshots +- define immutable vs mutable child-data behavior across revisions -* กำหนด revision snapshot strategy ให้ชัดเจน - -Priority: - -* Medium - -### 8. CRM Foreign Keys - -Customer -Contact -Enquiry -Quotation - -Current: - -* ใช้ organization validation ใน application layer +### CRM foreign keys +Core CRM tables still depend on organization-scoped validation in service code. Future: - -* เพิ่ม FK เมื่อ schema stable -* review cascade strategy - -Priority: - -* High - -### 9. Approval Workflow Pending - -Quotation stabilization in Task E.1 only prepares: - -* approval-ready statuses -* revision guard -* placeholder submit-for-approval action - -Current: - -* no approval table -* no approval requester/approver history -* no multi-step approval rule -* no approval notification - -Future: - -* introduce approval aggregate in Task F -* separate workflow state from quotation commercial state where needed -* define approver resolution by branch / amount / role - -Priority: - -* High +- add FK constraints after schema churn slows down +- review delete and soft-delete interactions first ## After Task F -### Approval Permission Sync -Existing memberships may not automatically receive new approval permissions. +### Permission sync for existing memberships +New permissions are defined in code, but old membership rows may still hold stale permission arrays. Future: -- add permission sync script -- admin UI for role/permission management +- add a permission sync/backfill command +- provide admin tooling for role and permission maintenance -### Approval Workflow Config UI -Approval workflow is seeded but not configurable from UI. +### Approval workflow configuration UI +Approval workflows are seeded and usable, but still not configurable from the dashboard. Future: -- workflow setup screen -- step editor -- approver role mapping +- build workflow setup screens +- add step editing and role mapping management -### Approval Notification -Approval workflow has no email/line notification yet. +### Approval notifications +Approval flow has no queue-backed notification channel yet. Future: -- notification queue -- approval reminder +- add notification jobs +- support reminders and escalation policy ## After Task G -### Document Mapping Editor -Template mappings are seeded and readable, but not yet manageable from UI. +### Document mapping editor +Template mappings are seeded and readable, but not manageable from UI. Future: -- mapping CRUD UI -- table column editor -- publish/draft template workflow +- add mapping CRUD for admins +- support table column editing +- add draft/publish template workflow -### pdfme Render Parity -Document preview is application-native, not pixel-perfect `pdfme` rendering. +### Product-specific template resolution +Quotation document template resolution still defaults to a coarse product selection strategy. Future: -- server-side pdfme render pipeline -- preview image generation -- schema validation against live template versions +- define clearer rules for crane, dock door, solar, and service-specific templates -### Approved PDF Persistence -Approved snapshot preparation exists, but no binary file generation or storage write-back yet. +## After Task H / H.1 + +### Font compatibility fallback +PDF generation now uses vendored Cordia TTF files from `public/fonts`, which fixed the TTC runtime issue, but generator-side fallback normalization still remains as a safety net. Future: -- approved PDF generator -- storage abstraction -- immutable approved artifact URL on quotation +- keep validating Thai text metrics against designer output +- remove fallback normalization only after render parity is proven stable -## After Task H - -### Font Render Parity -`pdfme` generation currently falls back from `cordia/cordiaBold` to standard fonts when TTC-backed runtime rendering is not reliable. +### Local filesystem storage +Approved PDFs currently persist under `public/generated/quotations//`. Future: -- package or vendor the exact Cordia font assets in a generator-friendly format -- remove fallback normalization -- verify Thai text metrics against the original designer output +- replace local public storage for production with object storage +- add signed/private delivery where required +- define backup and cleanup expectations -### Local PDF Storage -Approved PDFs currently persist to local `public/generated/...` paths. +### Approved artifact immutability +Approved PDF persistence exists, but the lifecycle is not immutable by policy yet. Future: -- object storage provider abstraction -- signed/private delivery strategy -- retention and regeneration policy +- define whether regeneration is allowed after approval +- version approved artifacts explicitly if regeneration must remain possible +- document which snapshot fields are contractual records -### Post-Approval Automation +### Post-approval automation Approved PDF generation is still manual by design. Future: -- optional auto-generate hook after final approval -- retry-safe job queue for PDF generation +- add a non-blocking auto-generation hook after final approval +- move heavy generation/retry behavior to a background job when needed -## After Task G +### PDF pipeline test coverage +Task H.1 added dev fixture and HTTP verification scripts, but there is still no CI-backed E2E coverage for the PDF pipeline. -### Template Mapping Editor -Mapping CRUD UI ยังไม่มี -Future: ทำ mapping editor สำหรับ admin - -### Canvas-Accurate PDF Preview -Preview ปัจจุบันเป็น native app preview ไม่ใช่ pdfme canvas rendering -Future: เพิ่ม pdfme renderer preview - -### Product-Specific Template Rule -ตอนนี้ใช้ productType = default -Future: define rule สำหรับ crane/dockdoor/solarcell - -### PDF Binary Generation -ยังไม่มี generate/download/store PDF -Future: Task H +Future: +- promote fixture verification into automated test coverage +- add regression checks for template mappings, storage persistence, and permission enforcement diff --git a/package.json b/package.json index 5246445..62e7089 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "studio": "npx drizzle-kit studio", "seed:super-admin": "node scripts/seed-super-admin.js", "seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", + "seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js", + "verify:task-h1": "node scripts/verify-task-h1.js", "setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation" }, "dependencies": { diff --git a/plans/task-d.1.md b/plans/task-d.1.md new file mode 100644 index 0000000..8910dda --- /dev/null +++ b/plans/task-d.1.md @@ -0,0 +1,251 @@ +# Task D.1: Enquiry Assignment + CRM Role Permission Stabilization + +## Goal + +เพิ่มระบบ Assign Sales ให้ Enquiry และปรับ Role/Permission ให้รองรับ ALLA OS CRM ไม่ใช่เฉพาะ IT-CENTER template + +## Must Read + +```txt +docs/implementation/task-a-template-audit.md +docs/implementation/task-b-template-audit.md +docs/implementation/task-b1-foundation-stabilization.md +src/features/crm/enquiries/** +src/features/crm/customers/** +src/features/foundation/** +src/lib/auth/rbac.ts +src/db/schema.ts +src/db/seeds/foundation.seed.ts +``` + +## Scope 1: Audit Current Enquiry Assignment + +ตรวจสอบว่า `crm_enquiries` มี field เหล่านี้หรือยัง: + +```txt +assignedToUserId +assignedAt +assignedBy +assignmentRemark +``` + +ตรวจสอบ UI: + +```txt +Assign Sales +Reassign Sales +My Enquiries +Assigned Enquiries +``` + +ตรวจสอบ permission: + +```txt +crm.enquiry.assign +crm.enquiry.reassign +``` + +ถ้าไม่มี ให้ implement ตาม scope ถัดไป + +## Scope 2: Schema + +เพิ่ม field ใน `crm_enquiries`: + +```txt +assignedToUserId +assignedAt +assignedBy +assignmentRemark +``` + +Rules: + +* assignedToUserId อ้างถึง user ใน organization เดียวกัน +* assignedBy คือ current user +* assignedAt คือเวลาที่ assign ล่าสุด +* assignmentRemark optional +* soft delete ไม่เกี่ยวกับ assignment + +## Scope 3: API + +เพิ่ม route: + +```txt +POST /api/crm/enquiries/[id]/assign +POST /api/crm/enquiries/[id]/reassign +``` + +Payload: + +```ts +{ + assignedToUserId: string + remark?: string +} +``` + +Rules: + +* ต้อง require organization access +* ต้อง validate assignee อยู่ใน organization เดียวกัน +* assign/reassign ต้อง audit +* assign ครั้งแรกใช้ action = assign +* เปลี่ยนคนใช้ action = reassign + +## Scope 4: Permission + +เพิ่ม permission: + +```txt +crm.enquiry.assign +crm.enquiry.reassign +``` + +Role recommendation: + +```txt +super_admin: all permissions +organization_admin: all crm permissions +sales_manager: read/create/update/assign/reassign enquiry +sales: read/create/update own/assigned enquiry +sales_support: read/create/update enquiry +viewer: read only +``` + +อย่า hardcode เฉพาะ IT-CENTER roles + +## Scope 5: UI + +ใน Enquiry Detail เพิ่ม action: + +```txt +Assign Sales +Reassign Sales +``` + +แสดงเมื่อ: + +```txt +user has crm.enquiry.assign หรือ crm.enquiry.reassign +``` + +แสดงข้อมูล: + +```txt +Assigned Sales +Assigned At +Assigned By +Assignment Remark +``` + +ใน Enquiry List เพิ่ม column: + +```txt +Assigned Sales +``` + +## Scope 6: Form + +ใช้ Sheet/Dialog pattern + +Field: + +```txt +Sales User +Remark +``` + +Sales User dropdown ต้องดึงจาก users/memberships ใน organization ปัจจุบัน + +ห้าม hardcode user + +## Scope 7: Audit + +Audit entity: + +```txt +crm_enquiry +``` + +Actions: + +```txt +assign +reassign +``` + +beforeData: + +```txt +oldAssignedToUserId +``` + +afterData: + +```txt +newAssignedToUserId +remark +``` + +## Scope 8: Membership/Permission Stabilization + +ตรวจ Workspace Membership UI ว่ายังเป็น IT-CENTER template อยู่หรือไม่ + +ถ้ายังเป็น IT-CENTER ให้เพิ่ม CRM permissions เข้าไปก่อน โดยไม่ลบของเดิมทันที + +เพิ่ม permission groups: + +```txt +crm.customer.* +crm.contact.* +crm.enquiry.* +crm.quotation.* +crm.approval.* +crm.document_template.* +``` + +อย่างน้อยต้องมี: + +```txt +crm.enquiry.read +crm.enquiry.create +crm.enquiry.update +crm.enquiry.delete +crm.enquiry.assign +crm.enquiry.reassign +``` + +## ห้ามทำ + +```txt +Quotation assignment +Approval assignment +Notification +Dashboard KPI +Report +Major RBAC rewrite +``` + +## Output + +สรุป: + +1. Files Added +2. Files Modified +3. Schema Added +4. API Routes Added +5. Permissions Added +6. UI Added +7. Audit Integration +8. Remaining Risks + +## Definition of Done + +* Enquiry assign sales ได้ +* Reassign sales ได้ +* Enquiry list แสดง assigned sales +* Enquiry detail แสดง assigned sales +* Permission guard ทำงาน +* super_admin เห็นปุ่ม assign +* audit log ถูกสร้าง +* ไม่กระทบ Customer/Quotation/Approval diff --git a/plans/task-h.1.md b/plans/task-h.1.md new file mode 100644 index 0000000..7dc783f --- /dev/null +++ b/plans/task-h.1.md @@ -0,0 +1,302 @@ +# Task H.1: PDF Generation Stabilization + +## Goal + +Stabilize Task H PDF generation before starting Task I. + +Task H already added: + +* pdfme server-side generation +* preview/download PDF APIs +* approved PDF generation +* approved snapshot persistence +* approved PDF attachment metadata +* quotation detail PDF actions + +Task H.1 focuses on validation, hardening, and documentation. + +--- + +## Must Read + +```txt +docs/implementation/technical-debt.md +docs/adr/0008-attachment-storage-strategy.md + +src/features/foundation/pdf-generator/** +src/features/foundation/document-template/** +src/features/crm/quotations/document/** +src/features/crm/quotations/** +src/features/foundation/approval/** +src/app/api/crm/quotations/[id]/pdf-preview/route.ts +src/app/api/crm/quotations/[id]/pdf-download/route.ts +src/app/api/crm/quotations/[id]/approved-pdf/route.ts +src/db/schema.ts +``` + +--- + +## Scope 1: Smoke Test With Real Quotation + +Create or identify one real quotation record in dev database. + +The record must include: + +```txt +customer +contact +enquiry optional +at least one item +quotation customer role +scope topic +exclusion topic +payment topic +approved status or approval flow completed +``` + +Then verify: + +```txt +Preview PDF +Download PDF +Generate Approved PDF +View Approved PDF +``` + +If no real record exists, create a minimal seed/dev fixture. + +Rules: + +* dev fixture must be organization-scoped +* do not hardcode organizationId +* do not pollute production seed +* place fixture under dev-only seed or clearly named script + +--- + +## Scope 2: PDF Output Validation + +Check generated PDF for: + +```txt +file opens correctly +page count is reasonable +customer block appears +quotation code appears +item table appears +total amount appears +scope/exclusion/payment appears +approval block appears +signature placeholder appears +``` + +If layout breaks: + +* adjust mapping normalization +* adjust template input preparation +* do not rewrite template designer +* do not hardcode quotation-specific hacks into generic pdf generator + +--- + +## Scope 3: Approved PDF API Validation + +Validate: + +```txt +GET /api/crm/quotations/[id]/pdf-preview +GET /api/crm/quotations/[id]/pdf-download +POST /api/crm/quotations/[id]/approved-pdf +GET /api/crm/quotations/[id]/approved-pdf +``` + +Expected behavior: + +* preview returns inline PDF +* download returns attachment PDF +* approved-pdf POST only works for approved quotation +* approved-pdf GET returns stored file if available +* non-approved quotation cannot generate approved PDF + +--- + +## Scope 4: Persistence Validation + +After approved PDF generation verify: + +```txt +quotation.approvedPdfUrl populated +quotation.approvedSnapshot populated +quotation.approvedTemplateVersionId populated +quotation attachment metadata created or updated +audit log generated +file exists in public/generated/quotations// +``` + +If any part is missing, fix only the persistence pipeline. + +--- + +## Scope 5: Permission Validation + +Verify permissions: + +```txt +crm.quotation.pdf.preview +crm.quotation.pdf.download +crm.quotation.pdf.generate_approved +``` + +Check behavior for: + +```txt +super_admin +admin +regular user with permission +regular user without permission +``` + +Do not rewrite RBAC model. + +--- + +## Scope 6: Error Handling + +Improve user-safe/server-safe errors for: + +```txt +template not found +template version not found +mapping missing +quotation not approved +generated file missing +pdfme generation error +local storage write error +permission denied +``` + +Rules: + +* API must return meaningful status codes +* UI should show readable toast/message +* do not expose stack trace to client + +--- + +## Scope 7: Documentation / ADR + +Update: + +```txt +docs/implementation/technical-debt.md +``` + +Confirm or add: + +```txt +pdfme font fallback workaround +local filesystem storage limitation +missing object storage abstraction +approved PDF lifecycle not immutable yet +no full E2E test in CI yet +``` + +Create ADR if not exists: + +```txt +docs/adr/0009-approved-document-storage-lifecycle.md +``` + +Include: + +```txt +Current: +local public/generated storage + +Decision: +acceptable for development/MVP only + +Future: +object storage abstraction +signed URL +immutable approved artifacts +retention policy +``` + +--- + +## Scope 8: Optional Small Fix + +If approval finalization already has a clean hook point, add a non-blocking TODO or optional manual trigger note for: + +```txt +auto-generate approved PDF after final approval +``` + +Do not force automatic generation in Task H.1 if it risks breaking approval flow. + +--- + +## ห้ามทำ + +```txt +object storage production +signed URL service +template designer +mapping editor +email notification +report +dashboard KPI +large refactor +``` + +--- + +## Output + +After completion, summarize: + +1. Files Added +2. Files Modified +3. Smoke Test Record Used +4. PDF APIs Verified +5. Persistence Verified +6. Permission Verified +7. Error Handling Improved +8. ADR / Technical Debt Updated +9. Remaining Risks +10. Task I Readiness + +--- + +## Definition of Done + +Task H.1 passes when: + +✔ PDF preview works with a real quotation + +✔ PDF download works with a real quotation + +✔ Approved PDF generation works for approved quotation + +✔ Approved PDF GET returns stored PDF + +✔ Non-approved quotation cannot generate approved PDF + +✔ approvedSnapshot is saved + +✔ approvedPdfUrl is saved + +✔ approvedTemplateVersionId is saved + +✔ attachment metadata is created/updated + +✔ audit log exists for approved PDF generation + +✔ permissions are enforced + +✔ errors are user-safe + +✔ storage lifecycle ADR exists + +✔ ready for Task I diff --git a/scripts/seed-task-h1-fixture.js b/scripts/seed-task-h1-fixture.js new file mode 100644 index 0000000..4686a43 --- /dev/null +++ b/scripts/seed-task-h1-fixture.js @@ -0,0 +1,955 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { hash } = require('bcryptjs'); +const postgres = require('postgres'); + +const TASK_H1_PASSWORD = 'TaskH1Pass!2026'; +const FIXTURE_CODES = { + customer: 'CUS-TASK-H1', + approvedQuotation: 'QT-TASK-H1-APPROVED', + draftQuotation: 'QT-TASK-H1-DRAFT' +}; + +const FIXTURE_USERS = { + admin: 'task-h1-admin@local.test', + pdfUser: 'task-h1-pdf-user@local.test', + viewer: 'task-h1-viewer@local.test' +}; + +const PDF_PERMISSIONS = [ + 'crm.quotation.read', + 'crm.quotation.document.preview', + 'crm.quotation.pdf.preview', + 'crm.quotation.pdf.download', + 'crm.quotation.pdf.generate_approved' +]; + +function loadEnvFile(filePath) { + 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; + } + } +} + +function loadLocalEnv() { + loadEnvFile(path.resolve(process.cwd(), '.env.local')); + loadEnvFile(path.resolve(process.cwd(), '.env')); +} + +function requireEnv(name) { + const value = process.env[name]?.trim(); + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +async function resolveOptionId(sql, organizationId, category, code) { + const [row] = await sql` + select id + from ms_options + where organization_id = ${organizationId} + and category = ${category} + and code = ${code} + and is_active = true + and deleted_at is null + limit 1 + `; + + if (!row) { + throw new Error(`Missing option ${category}:${code}`); + } + + return row.id; +} + +async function resolveApprovalWorkflow(sql, organizationId) { + const [workflow] = await sql` + select id + from crm_approval_workflows + where organization_id = ${organizationId} + and code = 'quotation_standard_approval' + and is_active = true + and deleted_at is null + limit 1 + `; + + if (!workflow) { + throw new Error('Missing quotation_standard_approval workflow'); + } + + const steps = await sql` + select step_number as "stepNumber", role_code as "roleCode", role_name as "roleName" + from crm_approval_steps + where organization_id = ${organizationId} + and workflow_id = ${workflow.id} + and deleted_at is null + order by step_number asc + `; + + if (!steps.length) { + throw new Error('Approval workflow has no steps'); + } + + return { + id: workflow.id, + steps + }; +} + +async function ensureUser(sql, organization, creatorId, email, name, membership) { + const passwordHash = await hash(TASK_H1_PASSWORD, 10); + const existing = + ( + await sql` + select id + from users + where email = ${email} + limit 1 + ` + )[0] ?? null; + const userId = existing?.id ?? crypto.randomUUID(); + + if (existing) { + await sql` + update users + set + name = ${name}, + password_hash = ${passwordHash}, + system_role = 'user', + active_organization_id = ${organization.id}, + updated_at = now() + where id = ${userId} + `; + } else { + await sql` + insert into users ( + id, + name, + email, + password_hash, + system_role, + active_organization_id + ) values ( + ${userId}, + ${name}, + ${email}, + ${passwordHash}, + 'user', + ${organization.id} + ) + `; + } + + const membershipRow = + ( + await sql` + select id + from memberships + where user_id = ${userId} + and organization_id = ${organization.id} + limit 1 + ` + )[0] ?? null; + + if (membershipRow) { + await sql` + update memberships + set + role = ${membership.role}, + business_role = ${membership.businessRole}, + permissions = ${membership.permissions}, + updated_at = now() + where id = ${membershipRow.id} + `; + } else { + await sql` + insert into memberships ( + id, + user_id, + organization_id, + role, + business_role, + permissions + ) values ( + ${crypto.randomUUID()}, + ${userId}, + ${organization.id}, + ${membership.role}, + ${membership.businessRole}, + ${membership.permissions} + ) + `; + } + + return { + id: userId, + email + }; +} + +async function ensureCustomerAndContact(sql, organization, creatorId, optionIds) { + const existingCustomer = + ( + await sql` + select id + from crm_customers + where organization_id = ${organization.id} + and code = ${FIXTURE_CODES.customer} + limit 1 + ` + )[0] ?? null; + const customerId = existingCustomer?.id ?? crypto.randomUUID(); + + if (existingCustomer) { + await sql` + update crm_customers + set + branch_id = ${optionIds.branchId}, + name = 'Task H.1 Demo Customer', + abbr = 'TASKH1', + tax_id = '0105559001122', + customer_type = ${optionIds.customerTypeCompanyId}, + customer_status = ${optionIds.customerStatusActiveId}, + address = '99/9 Rama IX Road, Bangkok 10310', + phone = '02-000-1000', + email = 'demo-customer@local.test', + lead_channel = ${optionIds.leadChannelSalesId}, + customer_group = ${optionIds.customerGroupStandardId}, + notes = 'Task H.1 smoke-test customer', + is_active = true, + deleted_at = null, + updated_at = now(), + updated_by = ${creatorId} + where id = ${customerId} + `; + } else { + await sql` + insert into crm_customers ( + id, + organization_id, + branch_id, + code, + name, + abbr, + tax_id, + customer_type, + customer_status, + address, + phone, + email, + lead_channel, + customer_group, + notes, + is_active, + created_by, + updated_by + ) values ( + ${customerId}, + ${organization.id}, + ${optionIds.branchId}, + ${FIXTURE_CODES.customer}, + 'Task H.1 Demo Customer', + 'TASKH1', + '0105559001122', + ${optionIds.customerTypeCompanyId}, + ${optionIds.customerStatusActiveId}, + '99/9 Rama IX Road, Bangkok 10310', + '02-000-1000', + 'demo-customer@local.test', + ${optionIds.leadChannelSalesId}, + ${optionIds.customerGroupStandardId}, + 'Task H.1 smoke-test customer', + true, + ${creatorId}, + ${creatorId} + ) + `; + } + + const existingContact = + ( + await sql` + select id + from crm_customer_contacts + where organization_id = ${organization.id} + and customer_id = ${customerId} + and email = 'demo-contact@local.test' + limit 1 + ` + )[0] ?? null; + const contactId = existingContact?.id ?? crypto.randomUUID(); + + if (existingContact) { + await sql` + update crm_customer_contacts + set + name = 'Task H.1 Contact', + position = 'Procurement Manager', + department = 'Procurement', + phone = '02-000-1001', + mobile = '081-000-1001', + email = 'demo-contact@local.test', + is_primary = true, + notes = 'Primary smoke-test contact', + is_active = true, + deleted_at = null, + updated_at = now(), + updated_by = ${creatorId} + where id = ${contactId} + `; + } else { + await sql` + insert into crm_customer_contacts ( + id, + organization_id, + customer_id, + name, + position, + department, + phone, + mobile, + email, + is_primary, + notes, + is_active, + created_by, + updated_by + ) values ( + ${contactId}, + ${organization.id}, + ${customerId}, + 'Task H.1 Contact', + 'Procurement Manager', + 'Procurement', + '02-000-1001', + '081-000-1001', + 'demo-contact@local.test', + true, + 'Primary smoke-test contact', + true, + ${creatorId}, + ${creatorId} + ) + `; + } + + return { + customerId, + contactId + }; +} + +async function upsertQuotation(sql, args) { + const { + organization, + creatorId, + workflow, + optionIds, + customerId, + contactId, + code, + approved + } = args; + const existing = + ( + await sql` + select id + from crm_quotations + where organization_id = ${organization.id} + and code = ${code} + limit 1 + ` + )[0] ?? null; + const quotationId = existing?.id ?? crypto.randomUUID(); + const statusId = approved ? optionIds.quotationStatusApprovedId : optionIds.quotationStatusDraftId; + const approvalNow = approved ? new Date().toISOString() : null; + const notes = approved + ? 'Approved quotation fixture for Task H.1 PDF stabilization.' + : 'Draft quotation fixture for Task H.1 negative API validation.'; + + if (existing) { + await sql` + update crm_quotations + set + branch_id = ${optionIds.branchId}, + enquiry_id = null, + customer_id = ${customerId}, + contact_id = ${contactId}, + quotation_date = ${'2026-06-16T00:00:00.000Z'}, + valid_until = ${'2026-07-16T00:00:00.000Z'}, + quotation_type = ${optionIds.quotationTypeServiceId}, + project_name = ${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'}, + project_location = 'Bangkok HQ', + attention = 'Task H.1 Contact', + reference = ${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'}, + notes = ${notes}, + status = ${statusId}, + revision = 0, + parent_quotation_id = null, + revision_remark = null, + currency = ${optionIds.currencyThbId}, + exchange_rate = 1, + subtotal = 125000, + discount = 5000, + discount_type = ${optionIds.discountPercentageId}, + tax_rate = 7, + tax_amount = 8400, + total_amount = 128400, + chance_percent = 90, + is_hot_project = true, + competitor = 'Task H.1 Competitor', + salesman_id = ${creatorId}, + is_sent = ${approved}, + sent_at = ${approved ? '2026-06-16T01:00:00.000Z' : null}, + sent_via = ${optionIds.sentViaEmailId}, + approved_at = ${approvalNow}, + approved_pdf_url = null, + approved_snapshot = null, + approved_template_version_id = null, + accepted_at = null, + rejected_at = null, + rejection_reason = null, + is_active = true, + deleted_at = null, + updated_at = now(), + updated_by = ${creatorId} + where id = ${quotationId} + `; + } else { + await sql` + insert into crm_quotations ( + id, + organization_id, + branch_id, + code, + enquiry_id, + customer_id, + contact_id, + quotation_date, + valid_until, + quotation_type, + project_name, + project_location, + attention, + reference, + notes, + status, + revision, + parent_quotation_id, + revision_remark, + currency, + exchange_rate, + subtotal, + discount, + discount_type, + tax_rate, + tax_amount, + total_amount, + chance_percent, + is_hot_project, + competitor, + salesman_id, + is_sent, + sent_at, + sent_via, + approved_at, + approved_pdf_url, + approved_snapshot, + approved_template_version_id, + accepted_at, + rejected_at, + rejection_reason, + is_active, + created_by, + updated_by + ) values ( + ${quotationId}, + ${organization.id}, + ${optionIds.branchId}, + ${code}, + null, + ${customerId}, + ${contactId}, + ${'2026-06-16T00:00:00.000Z'}, + ${'2026-07-16T00:00:00.000Z'}, + ${optionIds.quotationTypeServiceId}, + ${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'}, + 'Bangkok HQ', + 'Task H.1 Contact', + ${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'}, + ${notes}, + ${statusId}, + 0, + null, + null, + ${optionIds.currencyThbId}, + 1, + 125000, + 5000, + ${optionIds.discountPercentageId}, + 7, + 8400, + 128400, + 90, + true, + 'Task H.1 Competitor', + ${creatorId}, + ${approved}, + ${approved ? '2026-06-16T01:00:00.000Z' : null}, + ${optionIds.sentViaEmailId}, + ${approvalNow}, + null, + null, + null, + null, + null, + null, + true, + ${creatorId}, + ${creatorId} + ) + `; + } + + await sql`delete from crm_quotation_attachments where quotation_id = ${quotationId}`; + await sql` + delete from crm_approval_actions + where approval_request_id in ( + select id + from crm_approval_requests + where entity_type = 'quotation' + and entity_id = ${quotationId} + ) + `; + await sql` + delete from crm_approval_requests + where entity_type = 'quotation' + and entity_id = ${quotationId} + `; + await sql`delete from crm_quotation_customers where quotation_id = ${quotationId}`; + await sql` + delete from crm_quotation_topic_items + where topic_id in ( + select id + from crm_quotation_topics + where quotation_id = ${quotationId} + ) + `; + await sql`delete from crm_quotation_topics where quotation_id = ${quotationId}`; + await sql`delete from crm_quotation_items where quotation_id = ${quotationId}`; + + await sql` + insert into crm_quotation_items ( + id, + organization_id, + quotation_id, + item_number, + product_type, + description, + quantity, + unit, + unit_price, + discount, + discount_type, + tax_rate, + total_price, + notes, + sort_order + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${quotationId}, + 1, + ${optionIds.productTypeCraneId}, + '1 set overhead crane with installation and commissioning', + 1, + ${optionIds.unitSetId}, + 125000, + 5000, + ${optionIds.discountPercentageId}, + 7, + 120000, + 'Smoke-test commercial line', + 1 + ) + `; + + await sql` + insert into crm_quotation_customers ( + id, + organization_id, + quotation_id, + customer_id, + role, + is_primary + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${quotationId}, + ${customerId}, + ${optionIds.customerRoleOwnerId}, + true + ) + `; + + const topics = [ + { + typeId: optionIds.topicTypeScopeId, + title: 'Scope of work', + items: ['Supply crane equipment', 'Install and test the system'] + }, + { + typeId: optionIds.topicTypeExclusionId, + title: 'Exclusions', + items: ['Civil work by customer', 'Power feeder by customer'] + }, + { + typeId: optionIds.topicTypePaymentId, + title: 'Payment terms', + items: ['50% upon PO', '40% before delivery', '10% after commissioning'] + } + ]; + + for (const [topicIndex, topic] of topics.entries()) { + const topicId = crypto.randomUUID(); + + await sql` + insert into crm_quotation_topics ( + id, + organization_id, + quotation_id, + topic_type, + title, + sort_order + ) values ( + ${topicId}, + ${organization.id}, + ${quotationId}, + ${topic.typeId}, + ${topic.title}, + ${topicIndex + 1} + ) + `; + + for (const [itemIndex, content] of topic.items.entries()) { + await sql` + insert into crm_quotation_topic_items ( + id, + organization_id, + topic_id, + content, + sort_order + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${topicId}, + ${content}, + ${itemIndex + 1} + ) + `; + } + } + + if (approved) { + const approvalRequestId = crypto.randomUUID(); + + await sql` + insert into crm_approval_requests ( + id, + organization_id, + workflow_id, + entity_type, + entity_id, + current_step, + status, + requested_by, + requested_at, + completed_at + ) values ( + ${approvalRequestId}, + ${organization.id}, + ${workflow.id}, + 'quotation', + ${quotationId}, + ${workflow.steps[workflow.steps.length - 1].stepNumber}, + 'approved', + ${creatorId}, + ${'2026-06-16T00:30:00.000Z'}, + ${'2026-06-16T01:30:00.000Z'} + ) + `; + + await sql` + insert into crm_approval_actions ( + id, + organization_id, + approval_request_id, + step_number, + action, + remark, + acted_by, + acted_at + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${approvalRequestId}, + 0, + 'submit', + 'Task H.1 fixture submission', + ${creatorId}, + ${'2026-06-16T00:30:00.000Z'} + ) + `; + + for (const [index, step] of workflow.steps.entries()) { + await sql` + insert into crm_approval_actions ( + id, + organization_id, + approval_request_id, + step_number, + action, + remark, + acted_by, + acted_at + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${approvalRequestId}, + ${step.stepNumber}, + 'approve', + ${`Task H.1 fixture ${step.roleCode} approval`}, + ${creatorId}, + ${new Date(Date.UTC(2026, 5, 16, 1, index * 10, 0)).toISOString()} + ) + `; + } + } + + return quotationId; +} + +async function main() { + loadLocalEnv(); + + const sql = postgres(requireEnv('DATABASE_URL'), { prepare: false }); + + try { + const organization = + ( + await sql` + select id, name, slug, created_by as "createdBy" + from organizations + order by created_at asc + limit 1 + ` + )[0] ?? null; + + if (!organization) { + throw new Error('No organization found for Task H.1 fixture'); + } + + const creatorId = + ( + await sql` + select id + from users + where system_role = 'super_admin' + order by created_at asc + limit 1 + ` + )[0]?.id ?? organization.createdBy; + + if (!creatorId) { + throw new Error('No creator user found for Task H.1 fixture'); + } + + const optionIds = { + branchId: await resolveOptionId(sql, organization.id, 'crm_branch', 'head_office'), + customerTypeCompanyId: await resolveOptionId( + sql, + organization.id, + 'crm_customer_type', + 'company' + ), + customerStatusActiveId: await resolveOptionId( + sql, + organization.id, + 'crm_customer_status', + 'active' + ), + leadChannelSalesId: await resolveOptionId(sql, organization.id, 'crm_lead_channel', 'sales'), + customerGroupStandardId: await resolveOptionId( + sql, + organization.id, + 'crm_customer_group', + 'standard' + ), + quotationStatusDraftId: await resolveOptionId( + sql, + organization.id, + 'crm_quotation_status', + 'draft' + ), + quotationStatusApprovedId: await resolveOptionId( + sql, + organization.id, + 'crm_quotation_status', + 'approved' + ), + quotationTypeServiceId: await resolveOptionId( + sql, + organization.id, + 'crm_quotation_type', + 'service' + ), + currencyThbId: await resolveOptionId(sql, organization.id, 'crm_currency', 'THB'), + discountPercentageId: await resolveOptionId( + sql, + organization.id, + 'crm_discount_type', + 'percentage' + ), + unitSetId: await resolveOptionId(sql, organization.id, 'crm_unit', 'set'), + productTypeCraneId: await resolveOptionId( + sql, + organization.id, + 'crm_product_type', + 'crane' + ), + customerRoleOwnerId: await resolveOptionId( + sql, + organization.id, + 'crm_quotation_customer_role', + 'owner' + ), + topicTypeScopeId: await resolveOptionId( + sql, + organization.id, + 'crm_quotation_topic_type', + 'scope' + ), + topicTypeExclusionId: await resolveOptionId( + sql, + organization.id, + 'crm_quotation_topic_type', + 'exclusion' + ), + topicTypePaymentId: await resolveOptionId( + sql, + organization.id, + 'crm_quotation_topic_type', + 'payment' + ), + sentViaEmailId: await resolveOptionId(sql, organization.id, 'crm_sent_via', 'email') + }; + const workflow = await resolveApprovalWorkflow(sql, organization.id); + const { customerId, contactId } = await ensureCustomerAndContact( + sql, + organization, + creatorId, + optionIds + ); + + const users = { + admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', { + role: 'admin', + businessRole: 'it_admin', + permissions: [...PDF_PERMISSIONS, 'users:manage'] + }), + pdfUser: await ensureUser( + sql, + organization, + creatorId, + FIXTURE_USERS.pdfUser, + 'Task H.1 PDF User', + { + role: 'user', + businessRole: 'viewer', + permissions: PDF_PERMISSIONS + } + ), + viewer: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.viewer, 'Task H.1 Viewer', { + role: 'user', + businessRole: 'viewer', + permissions: ['crm.quotation.read'] + }) + }; + + const approvedQuotationId = await upsertQuotation(sql, { + organization, + creatorId, + workflow, + optionIds, + customerId, + contactId, + code: FIXTURE_CODES.approvedQuotation, + approved: true + }); + const draftQuotationId = await upsertQuotation(sql, { + organization, + creatorId, + workflow, + optionIds, + customerId, + contactId, + code: FIXTURE_CODES.draftQuotation, + approved: false + }); + + console.log( + JSON.stringify( + { + organizationId: organization.id, + approvedQuotationId, + approvedQuotationCode: FIXTURE_CODES.approvedQuotation, + draftQuotationId, + draftQuotationCode: FIXTURE_CODES.draftQuotation, + users, + password: TASK_H1_PASSWORD + }, + null, + 2 + ) + ); + } finally { + await sql.end(); + } +} + +main().catch((error) => { + console.error('[seed-task-h1-fixture] Failed:', error.message); + process.exitCode = 1; +}); diff --git a/scripts/verify-task-h1.js b/scripts/verify-task-h1.js new file mode 100644 index 0000000..3631689 --- /dev/null +++ b/scripts/verify-task-h1.js @@ -0,0 +1,519 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const postgres = require('postgres'); + +const FIXTURE_USERS = { + superAdmin: process.env.SUPER_ADMIN_EMAIL || 'admin@allaos.com', + admin: 'task-h1-admin@local.test', + pdfUser: 'task-h1-pdf-user@local.test', + viewer: 'task-h1-viewer@local.test' +}; + +const PASSWORDS = { + superAdmin: process.env.SUPER_ADMIN_PASSWORD || 'A3YH2Xflw1@allaos', + fixture: 'TaskH1Pass!2026' +}; + +function loadEnvFile(filePath) { + 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; + } + } +} + +function loadLocalEnv() { + loadEnvFile(path.resolve(process.cwd(), '.env.local')); + loadEnvFile(path.resolve(process.cwd(), '.env')); +} + +function requireEnv(name) { + const value = process.env[name]?.trim(); + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function extractCookies(response) { + const cookieMap = new Map(); + const setCookies = typeof response.headers.getSetCookie === 'function' + ? response.headers.getSetCookie() + : response.headers.get('set-cookie') + ? [response.headers.get('set-cookie')] + : []; + + for (const rawCookie of setCookies) { + const [pair] = rawCookie.split(';', 1); + const separatorIndex = pair.indexOf('='); + + if (separatorIndex === -1) { + continue; + } + + cookieMap.set(pair.slice(0, separatorIndex), pair.slice(separatorIndex + 1)); + } + + return cookieMap; +} + +function mergeCookieMaps(base, next) { + const merged = new Map(base); + + for (const [key, value] of next.entries()) { + merged.set(key, value); + } + + return merged; +} + +function toCookieHeader(cookieMap) { + return [...cookieMap.entries()].map(([key, value]) => `${key}=${value}`).join('; '); +} + +async function signIn(baseUrl, email, password) { + const csrfResponse = await fetch(`${baseUrl}/api/auth/csrf`); + assert(csrfResponse.ok, `Unable to fetch CSRF token for ${email}`); + const csrfPayload = await csrfResponse.json(); + const cookies = extractCookies(csrfResponse); + const body = new URLSearchParams({ + email, + password, + csrfToken: csrfPayload.csrfToken, + callbackUrl: `${baseUrl}/dashboard`, + json: 'true' + }); + const signInResponse = await fetch(`${baseUrl}/api/auth/callback/credentials?json=true`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Cookie: toCookieHeader(cookies) + }, + body: body.toString(), + redirect: 'manual' + }); + + assert( + signInResponse.status === 200 || signInResponse.status === 302, + `Unexpected sign-in status ${signInResponse.status} for ${email}` + ); + + const mergedCookies = mergeCookieMaps(cookies, extractCookies(signInResponse)); + assert(mergedCookies.size > 0, `Sign-in did not return cookies for ${email}`); + + return mergedCookies; +} + +async function fetchWithAuth(baseUrl, cookieMap, pathname, init = {}) { + const response = await fetch(`${baseUrl}${pathname}`, { + ...init, + headers: { + ...(init.headers || {}), + Cookie: toCookieHeader(cookieMap) + }, + redirect: 'manual' + }); + const bytes = Buffer.from(await response.arrayBuffer()); + const contentType = response.headers.get('content-type'); + const disposition = response.headers.get('content-disposition'); + let json = null; + + if (contentType?.includes('application/json')) { + json = JSON.parse(bytes.toString('utf8')); + } + + return { + status: response.status, + contentType, + disposition, + bytes, + json + }; +} + +function estimatePageCount(buffer) { + const text = buffer.toString('latin1'); + + return { + startsWithPdf: text.startsWith('%PDF-'), + byteLength: buffer.length + }; +} + +async function queryFixtureState(sql, approvedQuotationId) { + const quotation = + ( + await sql` + select + id, + organization_id, + code, + approved_at, + approved_pdf_url, + approved_snapshot, + approved_template_version_id + from crm_quotations + where id = ${approvedQuotationId} + limit 1 + ` + )[0] ?? null; + + assert(quotation, 'Approved quotation fixture not found in DB'); + + const [attachment, audit] = await Promise.all([ + sql` + select + id, + file_name, + file_path, + file_type, + file_size, + description + from crm_quotation_attachments + where quotation_id = ${approvedQuotationId} + and deleted_at is null + order by created_at desc + limit 1 + `.then((rows) => rows[0] ?? null), + sql` + select + id, + entity_type, + entity_id, + action, + created_at + from tr_audit_logs + where organization_id = ${quotation.organization_id} + and entity_type = 'crm_quotation_pdf_generate_approved' + and entity_id = ${approvedQuotationId} + order by created_at desc + limit 1 + `.then((rows) => rows[0] ?? null) + ]); + + return { + quotation, + attachment, + audit + }; +} + +async function queryTemplatePageCount(sql, templateVersionId) { + const version = + ( + await sql` + select schema_json + from crm_document_template_versions + where id = ${templateVersionId} + limit 1 + ` + )[0] ?? null; + + const schemaJson = + typeof version?.schema_json === 'string' + ? JSON.parse(version.schema_json) + : version?.schema_json ?? null; + const schemas = schemaJson?.schemas; + + return Array.isArray(schemas) ? schemas.length : 0; +} + +async function main() { + loadLocalEnv(); + + const baseUrl = process.env.APP_URL || 'http://localhost:3000'; + const sql = postgres(requireEnv('DATABASE_URL'), { prepare: false }); + + try { + const approvedQuotation = + ( + await sql` + select id, code + from crm_quotations + where code = 'QT-TASK-H1-APPROVED' + limit 1 + ` + )[0] ?? null; + const draftQuotation = + ( + await sql` + select id, code + from crm_quotations + where code = 'QT-TASK-H1-DRAFT' + limit 1 + ` + )[0] ?? null; + + assert(approvedQuotation, 'Approved fixture quotation is missing'); + assert(draftQuotation, 'Draft fixture quotation is missing'); + + const superAdminCookies = await signIn(baseUrl, FIXTURE_USERS.superAdmin, PASSWORDS.superAdmin); + const adminCookies = await signIn(baseUrl, FIXTURE_USERS.admin, PASSWORDS.fixture); + const pdfUserCookies = await signIn(baseUrl, FIXTURE_USERS.pdfUser, PASSWORDS.fixture); + const viewerCookies = await signIn(baseUrl, FIXTURE_USERS.viewer, PASSWORDS.fixture); + + const preview = await fetchWithAuth( + baseUrl, + superAdminCookies, + `/api/crm/quotations/${approvedQuotation.id}/pdf-preview` + ); + assert(preview.status === 200, `Preview API failed with ${preview.status}`); + assert(preview.contentType?.includes('application/pdf'), 'Preview did not return PDF'); + assert(preview.disposition?.startsWith('inline;'), 'Preview was not inline'); + + const download = await fetchWithAuth( + baseUrl, + superAdminCookies, + `/api/crm/quotations/${approvedQuotation.id}/pdf-download` + ); + assert(download.status === 200, `Download API failed with ${download.status}`); + assert(download.contentType?.includes('application/pdf'), 'Download did not return PDF'); + assert(download.disposition?.startsWith('attachment;'), 'Download was not attachment'); + + const generateApproved = await fetchWithAuth( + baseUrl, + superAdminCookies, + `/api/crm/quotations/${approvedQuotation.id}/approved-pdf`, + { method: 'POST' } + ); + assert( + generateApproved.status === 200, + `Approved PDF POST failed with ${generateApproved.status}` + ); + assert( + generateApproved.disposition?.startsWith('attachment;'), + 'Approved PDF POST was not attachment' + ); + + const approvedGet = await fetchWithAuth( + baseUrl, + superAdminCookies, + `/api/crm/quotations/${approvedQuotation.id}/approved-pdf` + ); + assert(approvedGet.status === 200, `Approved PDF GET failed with ${approvedGet.status}`); + assert(approvedGet.disposition?.startsWith('inline;'), 'Approved PDF GET was not inline'); + + const nonApprovedGenerate = await fetchWithAuth( + baseUrl, + superAdminCookies, + `/api/crm/quotations/${draftQuotation.id}/approved-pdf`, + { method: 'POST' } + ); + assert( + nonApprovedGenerate.status === 400, + `Draft quotation POST expected 400, got ${nonApprovedGenerate.status}` + ); + + const adminPreview = await fetchWithAuth( + baseUrl, + adminCookies, + `/api/crm/quotations/${approvedQuotation.id}/pdf-preview` + ); + const pdfUserPreview = await fetchWithAuth( + baseUrl, + pdfUserCookies, + `/api/crm/quotations/${approvedQuotation.id}/pdf-preview` + ); + const viewerPreview = await fetchWithAuth( + baseUrl, + viewerCookies, + `/api/crm/quotations/${approvedQuotation.id}/pdf-preview` + ); + const viewerGenerate = await fetchWithAuth( + baseUrl, + viewerCookies, + `/api/crm/quotations/${approvedQuotation.id}/approved-pdf`, + { method: 'POST' } + ); + + assert(adminPreview.status === 200, `Admin preview expected 200, got ${adminPreview.status}`); + assert( + pdfUserPreview.status === 200, + `Regular permitted user preview expected 200, got ${pdfUserPreview.status}` + ); + assert( + viewerPreview.status === 403, + `Regular user without preview permission expected 403, got ${viewerPreview.status}` + ); + assert( + viewerGenerate.status === 403, + `Regular user without generate permission expected 403, got ${viewerGenerate.status}` + ); + + const persistence = await queryFixtureState(sql, approvedQuotation.id); + assert(persistence.quotation.approved_pdf_url, 'approvedPdfUrl was not saved'); + assert( + persistence.quotation.approved_template_version_id, + 'approvedTemplateVersionId was not saved' + ); + assert(persistence.quotation.approved_snapshot, 'approvedSnapshot was not saved'); + assert(persistence.attachment, 'Approved PDF attachment metadata was not created'); + assert(persistence.audit, 'Approved PDF audit log was not created'); + + const absoluteStoredPath = path.join( + process.cwd(), + 'public', + persistence.quotation.approved_pdf_url.replace(/^\//, '') + ); + assert(fs.existsSync(absoluteStoredPath), 'Stored approved PDF file is missing on disk'); + + const pdfShape = estimatePageCount(approvedGet.bytes); + const approvedSnapshot = persistence.quotation.approved_snapshot; + const templatePageCount = await queryTemplatePageCount( + sql, + persistence.quotation.approved_template_version_id + ); + + assert(pdfShape.startsWithPdf, 'Approved PDF response does not start with %PDF header'); + assert(pdfShape.byteLength > 1000, `Approved PDF size looks too small: ${pdfShape.byteLength}`); + assert(templatePageCount >= 1, `Template page count is not reasonable: ${templatePageCount}`); + assert( + approvedSnapshot.documentData?.customer?.name, + 'approvedSnapshot is missing customer block data' + ); + assert( + approvedSnapshot.documentData?.quotation?.code === approvedQuotation.code, + 'approvedSnapshot is missing quotation code' + ); + assert( + Array.isArray(approvedSnapshot.documentData?.items) && + approvedSnapshot.documentData.items.length >= 1, + 'approvedSnapshot is missing item table data' + ); + assert( + typeof approvedSnapshot.documentData?.quotation?.totalAmount === 'number', + 'approvedSnapshot is missing total amount' + ); + assert( + approvedSnapshot.documentData?.topics?.scope?.length >= 1, + 'approvedSnapshot is missing scope topic data' + ); + assert( + approvedSnapshot.documentData?.topics?.exclusion?.length >= 1, + 'approvedSnapshot is missing exclusion topic data' + ); + assert( + approvedSnapshot.documentData?.topics?.payment?.length >= 1, + 'approvedSnapshot is missing payment topic data' + ); + assert( + approvedSnapshot.documentData?.approval?.approvers?.length >= 1, + 'approvedSnapshot is missing approval block data' + ); + assert( + Object.values(approvedSnapshot.documentData?.signatures ?? {}).some(Boolean), + 'approvedSnapshot is missing signature placeholder data' + ); + + console.log( + JSON.stringify( + { + smokeTestRecord: { + approvedQuotationId: approvedQuotation.id, + approvedQuotationCode: approvedQuotation.code, + draftQuotationId: draftQuotation.id, + draftQuotationCode: draftQuotation.code + }, + apiChecks: { + preview: { + status: preview.status, + disposition: preview.disposition, + bytes: preview.bytes.length + }, + download: { + status: download.status, + disposition: download.disposition, + bytes: download.bytes.length + }, + approvedPost: { + status: generateApproved.status, + disposition: generateApproved.disposition, + bytes: generateApproved.bytes.length + }, + approvedGet: { + status: approvedGet.status, + disposition: approvedGet.disposition, + bytes: approvedGet.bytes.length + }, + nonApprovedPost: { + status: nonApprovedGenerate.status, + message: nonApprovedGenerate.json?.message ?? null + } + }, + permissionChecks: { + superAdminPreview: preview.status, + adminPreview: adminPreview.status, + regularUserWithPermissionPreview: pdfUserPreview.status, + regularUserWithoutPermissionPreview: viewerPreview.status, + regularUserWithoutPermissionGenerate: viewerGenerate.status + }, + persistence: { + approvedPdfUrl: persistence.quotation.approved_pdf_url, + approvedTemplateVersionId: persistence.quotation.approved_template_version_id, + attachmentId: persistence.attachment.id, + auditId: persistence.audit.id, + storedFileExists: true + }, + outputValidation: { + templatePageCount, + pdfByteLength: pdfShape.byteLength, + startsWithPdfHeader: pdfShape.startsWithPdf, + customerName: approvedSnapshot.documentData.customer.name, + quotationCode: approvedSnapshot.documentData.quotation.code, + itemCount: approvedSnapshot.documentData.items.length, + totalAmount: approvedSnapshot.documentData.quotation.totalAmount, + scopeTopicCount: approvedSnapshot.documentData.topics.scope.length, + exclusionTopicCount: approvedSnapshot.documentData.topics.exclusion.length, + paymentTopicCount: approvedSnapshot.documentData.topics.payment.length, + approvalStepCount: approvedSnapshot.documentData.approval.approvers.length + } + }, + null, + 2 + ) + ); + } finally { + await sql.end(); + } +} + +main().catch((error) => { + console.error('[verify-task-h1] Failed:', error.message); + process.exitCode = 1; +}); diff --git a/src/app/api/crm/quotations/[id]/approved-pdf/route.ts b/src/app/api/crm/quotations/[id]/approved-pdf/route.ts index e493df9..33ea636 100644 --- a/src/app/api/crm/quotations/[id]/approved-pdf/route.ts +++ b/src/app/api/crm/quotations/[id]/approved-pdf/route.ts @@ -30,9 +30,6 @@ export async function GET(_request: NextRequest, { params }: Params) { 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 approved PDF' }, { status: 500 }); } } @@ -70,9 +67,6 @@ export async function POST(_request: NextRequest, { params }: Params) { 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 generate approved PDF' }, { status: 500 }); } } diff --git a/src/app/api/crm/quotations/[id]/pdf-download/route.ts b/src/app/api/crm/quotations/[id]/pdf-download/route.ts index c697e13..d9c80ca 100644 --- a/src/app/api/crm/quotations/[id]/pdf-download/route.ts +++ b/src/app/api/crm/quotations/[id]/pdf-download/route.ts @@ -25,9 +25,6 @@ export async function GET(_request: NextRequest, { params }: Params) { 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 generate quotation PDF' }, { status: 500 }); } } diff --git a/src/app/api/crm/quotations/[id]/pdf-preview/route.ts b/src/app/api/crm/quotations/[id]/pdf-preview/route.ts index 2292e78..3b392a2 100644 --- a/src/app/api/crm/quotations/[id]/pdf-preview/route.ts +++ b/src/app/api/crm/quotations/[id]/pdf-preview/route.ts @@ -25,9 +25,6 @@ export async function GET(_request: NextRequest, { params }: Params) { 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 generate preview PDF' }, { status: 500 }); } } diff --git a/src/features/crm/quotations/document/server/service.ts b/src/features/crm/quotations/document/server/service.ts index 23e9a58..d4cac55 100644 --- a/src/features/crm/quotations/document/server/service.ts +++ b/src/features/crm/quotations/document/server/service.ts @@ -35,7 +35,8 @@ const DOCUMENT_OPTION_CATEGORIES = { discountType: 'crm_discount_type', unit: 'crm_unit', customerRole: 'crm_quotation_customer_role', - productType: 'crm_product_type' + productType: 'crm_product_type', + topicType: 'crm_quotation_topic_type' } as const; function toRevisionLabel(revision: number) { @@ -76,6 +77,72 @@ function findOptionCode( return id ? options.find((option) => option.id === id)?.code ?? null : null; } +function extractSinglePlaceholder(value: string) { + const matches = [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]); + const uniqueMatches = [...new Set(matches)]; + + return uniqueMatches.length === 1 ? uniqueMatches[0] : null; +} + +function normalizePdfmeTemplateInput( + schemaJson: unknown, + templateInput: Record +) { + const pages = + schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson + ? (schemaJson as { schemas?: unknown }).schemas + : null; + + if (!Array.isArray(pages)) { + return templateInput; + } + + const normalized = { ...templateInput }; + + for (const page of pages) { + if (!Array.isArray(page)) { + continue; + } + + for (const field of page) { + if (!field || typeof field !== 'object') { + continue; + } + + const tableField = field as { type?: unknown; content?: unknown }; + + if (tableField.type !== 'table' || typeof tableField.content !== 'string') { + continue; + } + + const placeholderKey = extractSinglePlaceholder(tableField.content); + + if (!placeholderKey) { + continue; + } + + const value = normalized[placeholderKey]; + + if (typeof value !== 'string') { + continue; + } + + const lines = value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + if (!lines.length) { + continue; + } + + normalized[placeholderKey] = JSON.stringify(lines.map((line) => [line])); + } + } + + return normalized; +} + export async function buildQuotationDocumentData( quotationId: string, organizationId: string @@ -95,6 +162,7 @@ export async function buildQuotationDocumentData( unitOptions, customerRoleOptions, productTypeOptions, + topicTypeOptions, approvalRequests ] = await Promise.all([ db @@ -114,6 +182,7 @@ export async function buildQuotationDocumentData( getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }), listApprovalRequests(organizationId, { entityType: 'quotation', entityId: quotationId, limit: 20 }) ]); @@ -180,6 +249,7 @@ export async function buildQuotationDocumentData( const relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item])); const statusCode = findOptionCode(statusOptions, quotation.status); const statusLabel = findOptionLabel(statusOptions, quotation.status); + const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code])); const approvalApprovers: QuotationDocumentApprovalStep[] = approvalDetail?.timeline .filter((item) => ['approve', 'reject', 'return'].includes(item.action)) @@ -284,16 +354,19 @@ export async function buildQuotationDocumentData( })), topics: { scope: topics - .filter((item) => item.topicType === 'scope') + .filter((item) => topicCodeById.get(item.topicType) === 'scope') .map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })), exclusion: topics - .filter((item) => item.topicType === 'exclusion') + .filter((item) => topicCodeById.get(item.topicType) === 'exclusion') .map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })), payment: topics - .filter((item) => item.topicType === 'payment') + .filter((item) => topicCodeById.get(item.topicType) === 'payment') .map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })), other: topics - .filter((item) => !['scope', 'exclusion', 'payment'].includes(item.topicType)) + .filter((item) => { + const topicCode = topicCodeById.get(item.topicType); + return !['scope', 'exclusion', 'payment'].includes(topicCode ?? ''); + }) .map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })) }, approval: { @@ -330,9 +403,14 @@ export async function getQuotationDocumentPreviewData( fileType: 'pdfme' }); const mappings = await resolveTemplateMappings(template.version.id, organizationId); - const templateInput = mapDocumentDataToTemplateInput( - documentData as unknown as Record, - mappings + + if (!mappings.length) { + throw new AuthError('Document template mappings are not configured for this template version', 409); + } + + const templateInput = normalizePdfmeTemplateInput( + template.version.schemaJson, + mapDocumentDataToTemplateInput(documentData as unknown as Record, mappings) ); return { diff --git a/src/features/foundation/approval/server/service.ts b/src/features/foundation/approval/server/service.ts index 067e648..6950e19 100644 --- a/src/features/foundation/approval/server/service.ts +++ b/src/features/foundation/approval/server/service.ts @@ -350,6 +350,7 @@ async function syncEntityStatusFromApproval( } if (approvalStatus === APPROVAL_REQUEST_STATUSES.approved) { + // TODO(task-h.1): add an async hook/job to auto-generate approved PDFs after final approval. await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'approved'); return; } diff --git a/src/features/foundation/pdf-generator/server/service.ts b/src/features/foundation/pdf-generator/server/service.ts index 5c95d01..3581960 100644 --- a/src/features/foundation/pdf-generator/server/service.ts +++ b/src/features/foundation/pdf-generator/server/service.ts @@ -107,13 +107,17 @@ export async function generatePdfFromTemplate(input: GeneratePdfFromTemplateInpu error instanceof Error && (error.message.includes('Font "') || error.message.includes('fontKitFont.layout')) ) { - pdf = await generate({ - ...generatorInput, - template: normalizeTemplateFonts(input.template), - options: undefined - }); + try { + pdf = await generate({ + ...generatorInput, + template: normalizeTemplateFonts(input.template), + options: undefined + }); + } catch { + throw new AuthError('Unable to generate PDF from the current template', 500); + } } else { - throw error; + throw new AuthError('Unable to generate PDF from the current template', 500); } } @@ -155,8 +159,15 @@ async function persistApprovedQuotationArtifact(args: { const absolutePath = path.join(directory, safeFileName); const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`; - await writeFile(absolutePath, args.buffer); - const fileStats = await stat(absolutePath); + try { + await writeFile(absolutePath, args.buffer); + } catch { + throw new AuthError('Unable to persist approved PDF to local storage', 500); + } + + const fileStats = await stat(absolutePath).catch(() => { + throw new AuthError('Approved PDF was written but could not be verified on disk', 500); + }); await db .update(crmQuotations) @@ -289,7 +300,9 @@ export async function getStoredApprovedQuotationPdf( const relativePath = quotation.approvedPdfUrl.replace(/^\//, ''); const absolutePath = path.join(process.cwd(), 'public', relativePath); - const buffer = await readFile(absolutePath); + const buffer = await readFile(absolutePath).catch(() => { + throw new AuthError('Approved PDF file is missing from local storage', 404); + }); return { buffer,