task-h.1 complate

This commit is contained in:
phaichayon
2026-06-16 14:25:26 +07:00
parent edee45375e
commit 9ae41e4f2c
14 changed files with 2238 additions and 201 deletions

1
.gitignore vendored
View File

@@ -87,6 +87,7 @@ web_modules/
# Next.js build output # Next.js build output
.next .next
out out
public/generated/
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt

View File

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

View File

@@ -2,234 +2,137 @@
## After Task D ## After Task D
### 1. Database FK ### Database foreign keys
Customer, contact, and enquiry relationships are still enforced mainly in application logic.
Customer / Contact / Enquiry ยัง enforce relationship ด้วย application logic
Future: Future:
- add FK constraints after the CRM schema settles
- review cascade behavior before enabling hard relational enforcement
* เพิ่ม FK หลัง schema stable ### Date input UX
Several forms still use plain `YYYY-MM-DD` text/date inputs.
### 2. Date Input
Form ยังใช้ text input แบบ YYYY-MM-DD
Future: Future:
- move to a shared date picker pattern
- standardize timezone handling for CRM forms
* เปลี่ยนเป็น Date Picker กลางของระบบ ### Related enquiry pagination
Customer detail can show related enquiries, but that view still has no dedicated pagination strategy.
### 3. Customer Related Enquiries Pagination
หน้า customer detail แสดง related enquiries แล้ว แต่ยังไม่มี pagination
Future: Future:
- add paging once real data volume grows
* เพิ่มเมื่อข้อมูลเริ่มเยอะ ### Follow-up activity timeline
Activity history still relies heavily on audit payloads instead of a curated domain timeline.
### 4. Follow-up Activity
Activity timeline ยังพึ่ง audit payload
Future: Future:
- build a combined activity view/service for CRM interactions
* ทำ combined activity view/service
---
## After Task E ## After Task E
### 5. Mixed Tax Rollup ### Mixed tax rollup
Quotation items support per-line tax input, but summary calculation still assumes a header-centric tax model.
Quotation item รองรับ taxRate ต่อรายการแล้ว
Current:
* quotation summary ยังใช้ header tax calculation
Future: Future:
- support mixed-tax aggregation from actual item rows
- align document totals and reporting with per-line tax logic
* รองรับ mixed tax per line ### Attachment storage abstraction
* aggregate tax จาก item จริง Quotation attachments started as metadata-only and approved PDFs now write to local disk, but there is still no real storage abstraction.
Priority:
* Medium
### 6. Attachment Storage
Attachment ยังเป็น metadata-only
Current:
* เก็บเฉพาะ metadata
Future: 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 ### Revision snapshot coverage
* upload service Revision copy currently focuses on quotation header, items, customers, and topics.
* download endpoint
* permission check
Priority:
* High
### 7. Revision Snapshot
Revision copy:
* header
* items
* customers
* topics
Current:
* ไม่ copy follow-ups
* ไม่ copy attachments
Future: Future:
- decide whether follow-ups and attachments belong in revision snapshots
- define immutable vs mutable child-data behavior across revisions
* กำหนด revision snapshot strategy ให้ชัดเจน ### CRM foreign keys
Core CRM tables still depend on organization-scoped validation in service code.
Priority:
* Medium
### 8. CRM Foreign Keys
Customer
Contact
Enquiry
Quotation
Current:
* ใช้ organization validation ใน application layer
Future: Future:
- add FK constraints after schema churn slows down
* เพิ่ม FK เมื่อ schema stable - review delete and soft-delete interactions first
* 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
## After Task F ## After Task F
### Approval Permission Sync ### Permission sync for existing memberships
Existing memberships may not automatically receive new approval permissions. New permissions are defined in code, but old membership rows may still hold stale permission arrays.
Future: Future:
- add permission sync script - add a permission sync/backfill command
- admin UI for role/permission management - provide admin tooling for role and permission maintenance
### Approval Workflow Config UI ### Approval workflow configuration UI
Approval workflow is seeded but not configurable from UI. Approval workflows are seeded and usable, but still not configurable from the dashboard.
Future: Future:
- workflow setup screen - build workflow setup screens
- step editor - add step editing and role mapping management
- approver role mapping
### Approval Notification ### Approval notifications
Approval workflow has no email/line notification yet. Approval flow has no queue-backed notification channel yet.
Future: Future:
- notification queue - add notification jobs
- approval reminder - support reminders and escalation policy
## After Task G ## After Task G
### Document Mapping Editor ### Document mapping editor
Template mappings are seeded and readable, but not yet manageable from UI. Template mappings are seeded and readable, but not manageable from UI.
Future: Future:
- mapping CRUD UI - add mapping CRUD for admins
- table column editor - support table column editing
- publish/draft template workflow - add draft/publish template workflow
### pdfme Render Parity ### Product-specific template resolution
Document preview is application-native, not pixel-perfect `pdfme` rendering. Quotation document template resolution still defaults to a coarse product selection strategy.
Future: Future:
- server-side pdfme render pipeline - define clearer rules for crane, dock door, solar, and service-specific templates
- preview image generation
- schema validation against live template versions
### Approved PDF Persistence ## After Task H / H.1
Approved snapshot preparation exists, but no binary file generation or storage write-back yet.
### 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: Future:
- approved PDF generator - keep validating Thai text metrics against designer output
- storage abstraction - remove fallback normalization only after render parity is proven stable
- immutable approved artifact URL on quotation
## After Task H ### Local filesystem storage
Approved PDFs currently persist under `public/generated/quotations/<organizationId>/`.
### Font Render Parity
`pdfme` generation currently falls back from `cordia/cordiaBold` to standard fonts when TTC-backed runtime rendering is not reliable.
Future: Future:
- package or vendor the exact Cordia font assets in a generator-friendly format - replace local public storage for production with object storage
- remove fallback normalization - add signed/private delivery where required
- verify Thai text metrics against the original designer output - define backup and cleanup expectations
### Local PDF Storage ### Approved artifact immutability
Approved PDFs currently persist to local `public/generated/...` paths. Approved PDF persistence exists, but the lifecycle is not immutable by policy yet.
Future: Future:
- object storage provider abstraction - define whether regeneration is allowed after approval
- signed/private delivery strategy - version approved artifacts explicitly if regeneration must remain possible
- retention and regeneration policy - document which snapshot fields are contractual records
### Post-Approval Automation ### Post-approval automation
Approved PDF generation is still manual by design. Approved PDF generation is still manual by design.
Future: Future:
- optional auto-generate hook after final approval - add a non-blocking auto-generation hook after final approval
- retry-safe job queue for PDF generation - 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 Future:
Mapping CRUD UI ยังไม่มี - promote fixture verification into automated test coverage
Future: ทำ mapping editor สำหรับ admin - add regression checks for template mappings, storage persistence, and permission enforcement
### 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

View File

@@ -17,6 +17,8 @@
"studio": "npx drizzle-kit studio", "studio": "npx drizzle-kit studio",
"seed:super-admin": "node scripts/seed-super-admin.js", "seed:super-admin": "node scripts/seed-super-admin.js",
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", "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" "setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
}, },
"dependencies": { "dependencies": {

251
plans/task-d.1.md Normal file
View File

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

302
plans/task-h.1.md Normal file
View File

@@ -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/<organizationId>/
```
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

View File

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

519
scripts/verify-task-h1.js Normal file
View File

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

View File

@@ -30,9 +30,6 @@ export async function GET(_request: NextRequest, { params }: Params) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status }); 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 }); 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) { if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status }); 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 }); return NextResponse.json({ message: 'Unable to generate approved PDF' }, { status: 500 });
} }
} }

View File

@@ -25,9 +25,6 @@ export async function GET(_request: NextRequest, { params }: Params) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status }); 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 }); return NextResponse.json({ message: 'Unable to generate quotation PDF' }, { status: 500 });
} }
} }

View File

@@ -25,9 +25,6 @@ export async function GET(_request: NextRequest, { params }: Params) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status }); 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 }); return NextResponse.json({ message: 'Unable to generate preview PDF' }, { status: 500 });
} }
} }

View File

@@ -35,7 +35,8 @@ const DOCUMENT_OPTION_CATEGORIES = {
discountType: 'crm_discount_type', discountType: 'crm_discount_type',
unit: 'crm_unit', unit: 'crm_unit',
customerRole: 'crm_quotation_customer_role', customerRole: 'crm_quotation_customer_role',
productType: 'crm_product_type' productType: 'crm_product_type',
topicType: 'crm_quotation_topic_type'
} as const; } as const;
function toRevisionLabel(revision: number) { function toRevisionLabel(revision: number) {
@@ -76,6 +77,72 @@ function findOptionCode(
return id ? options.find((option) => option.id === id)?.code ?? null : null; 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<string, unknown>
) {
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( export async function buildQuotationDocumentData(
quotationId: string, quotationId: string,
organizationId: string organizationId: string
@@ -95,6 +162,7 @@ export async function buildQuotationDocumentData(
unitOptions, unitOptions,
customerRoleOptions, customerRoleOptions,
productTypeOptions, productTypeOptions,
topicTypeOptions,
approvalRequests approvalRequests
] = await Promise.all([ ] = await Promise.all([
db db
@@ -114,6 +182,7 @@ export async function buildQuotationDocumentData(
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }),
listApprovalRequests(organizationId, { entityType: 'quotation', entityId: quotationId, limit: 20 }) 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 relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item]));
const statusCode = findOptionCode(statusOptions, quotation.status); const statusCode = findOptionCode(statusOptions, quotation.status);
const statusLabel = findOptionLabel(statusOptions, quotation.status); const statusLabel = findOptionLabel(statusOptions, quotation.status);
const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code]));
const approvalApprovers: QuotationDocumentApprovalStep[] = const approvalApprovers: QuotationDocumentApprovalStep[] =
approvalDetail?.timeline approvalDetail?.timeline
.filter((item) => ['approve', 'reject', 'return'].includes(item.action)) .filter((item) => ['approve', 'reject', 'return'].includes(item.action))
@@ -284,16 +354,19 @@ export async function buildQuotationDocumentData(
})), })),
topics: { topics: {
scope: 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) })), .map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
exclusion: topics 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) })), .map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
payment: topics 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) })), .map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
other: topics 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) })) .map((item) => ({ title: item.title, items: item.items.map((child) => child.content) }))
}, },
approval: { approval: {
@@ -330,9 +403,14 @@ export async function getQuotationDocumentPreviewData(
fileType: 'pdfme' fileType: 'pdfme'
}); });
const mappings = await resolveTemplateMappings(template.version.id, organizationId); const mappings = await resolveTemplateMappings(template.version.id, organizationId);
const templateInput = mapDocumentDataToTemplateInput(
documentData as unknown as Record<string, unknown>, if (!mappings.length) {
mappings 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<string, unknown>, mappings)
); );
return { return {

View File

@@ -350,6 +350,7 @@ async function syncEntityStatusFromApproval(
} }
if (approvalStatus === APPROVAL_REQUEST_STATUSES.approved) { 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'); await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'approved');
return; return;
} }

View File

@@ -107,13 +107,17 @@ export async function generatePdfFromTemplate(input: GeneratePdfFromTemplateInpu
error instanceof Error && error instanceof Error &&
(error.message.includes('Font "') || error.message.includes('fontKitFont.layout')) (error.message.includes('Font "') || error.message.includes('fontKitFont.layout'))
) { ) {
pdf = await generate({ try {
...generatorInput, pdf = await generate({
template: normalizeTemplateFonts(input.template), ...generatorInput,
options: undefined template: normalizeTemplateFonts(input.template),
}); options: undefined
});
} catch {
throw new AuthError('Unable to generate PDF from the current template', 500);
}
} else { } 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 absolutePath = path.join(directory, safeFileName);
const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`; const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`;
await writeFile(absolutePath, args.buffer); try {
const fileStats = await stat(absolutePath); 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 await db
.update(crmQuotations) .update(crmQuotations)
@@ -289,7 +300,9 @@ export async function getStoredApprovedQuotationPdf(
const relativePath = quotation.approvedPdfUrl.replace(/^\//, ''); const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
const absolutePath = path.join(process.cwd(), 'public', relativePath); 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 { return {
buffer, buffer,