This commit is contained in:
phaichayon
2026-06-16 17:01:29 +07:00
parent 90ee59d388
commit 0a484e0b45
28 changed files with 6483 additions and 277 deletions

View File

@@ -3,22 +3,30 @@ accepted
Context: Context:
Task H introduced server-side PDF generation for quotation preview, download, and approved-document persistence. 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/...`. Task H.1 validated the full path with a real quotation fixture and confirmed that approved artifacts originally persisted to local disk under `public/generated/...`.
Task I introduces a storage-provider abstraction and a database-backed document artifact model so approved PDFs are no longer tied to raw public paths as the source of truth.
Decision: 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`. - Use a storage provider abstraction for approved quotation PDFs.
- 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. - Support a local provider for development and an S3 / MinIO-compatible provider for production-oriented deployments.
- Treat this storage model as acceptable only for local/dev and early MVP deployment, not as the final production storage architecture. - Persist approved-document metadata in `crm_document_artifacts`.
- Persist `approved_artifact_id` on the quotation row as the primary approved-artifact reference.
- Keep `approved_pdf_url` as a compatibility field and allow `artifact:<artifactId>` references during the migration period.
- Lock approved PDF artifacts after generation and treat them as immutable-first records.
Consequences: 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. - Approved PDF persistence no longer depends on raw `public/generated/...` paths as the system of record.
- 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. - Secure download routes can resolve artifacts from database metadata and storage-provider reads.
- Local storage remains acceptable for development, but production-oriented deployments can move to S3/MinIO-compatible object storage without changing the quotation flow contract.
- Legacy approved PDFs stored under `public/generated/...` still require explicit migration and remain a temporary compatibility burden.
Future: Future:
- introduce an object-storage abstraction shared by generated documents and manual attachments
- migrate legacy approved PDFs into `crm_document_artifacts`
- extend the storage abstraction to manual attachments as well as generated artifacts
- support signed URLs or another private delivery strategy where required - support signed URLs or another private delivery strategy where required
- define immutable approved artifact rules and whether regeneration is allowed - define operator-facing void/replacement workflow for immutable approved artifacts
- add retention/cleanup policy for superseded or regenerated artifacts - add retention/cleanup policy for superseded or voided artifacts
- move approved-PDF generation to an async job when post-approval auto-generation is introduced - move approved-PDF generation to an async job when post-approval auto-generation is introduced

View File

@@ -0,0 +1,144 @@
# Task D.1: Enquiry Assignment and CRM Role Stabilization
## 1. Files Added
- `src/app/api/crm/enquiries/[id]/_schemas.ts`
- `src/app/api/crm/enquiries/[id]/assign/route.ts`
- `src/app/api/crm/enquiries/[id]/reassign/route.ts`
- `src/features/crm/enquiries/components/enquiry-assignment-dialog.tsx`
- `drizzle/0008_clean_justin_hammer.sql`
- `drizzle/meta/0008_snapshot.json`
## 2. Files Modified
- `src/db/schema.ts`
- `src/lib/auth/rbac.ts`
- `src/app/dashboard/crm/enquiries/page.tsx`
- `src/app/dashboard/crm/enquiries/[id]/page.tsx`
- `src/features/crm/enquiries/api/types.ts`
- `src/features/crm/enquiries/api/service.ts`
- `src/features/crm/enquiries/api/mutations.ts`
- `src/features/crm/enquiries/server/service.ts`
- `src/features/crm/enquiries/components/enquiry-columns.tsx`
- `src/features/crm/enquiries/components/enquiries-table.tsx`
- `src/features/crm/enquiries/components/enquiry-listing.tsx`
- `src/features/crm/enquiries/components/enquiry-detail.tsx`
- `src/features/crm/enquiries/components/enquiry-cell-action.tsx`
- `src/features/users/api/types.ts`
- `src/features/users/schemas/user.ts`
- `src/features/users/components/user-form-sheet.tsx`
- `src/app/api/users/_lib.ts`
- `src/app/api/users/route.ts`
- `src/app/api/organizations/route.ts`
- `src/features/foundation/auth-context/service.ts`
## 3. Schema Added
Added assignment fields to `crm_enquiries`:
- `assigned_to_user_id`
- `assigned_at`
- `assigned_by`
- `assignment_remark`
Rules implemented:
- assignee must belong to the same organization
- assigner is the current user
- latest assignment timestamp is persisted
- assignment remark is optional
## 4. API Routes Added
- `POST /api/crm/enquiries/[id]/assign`
- `POST /api/crm/enquiries/[id]/reassign`
Payload:
```ts
{
assignedToUserId: string;
remark?: string;
}
```
Behavior:
- requires organization access
- enforces `crm.enquiry.assign` or `crm.enquiry.reassign`
- validates assignee organization membership
- rejects assign on already-assigned enquiry
- rejects reassign on unassigned enquiry
## 5. Permissions Added
- `crm.enquiry.assign`
- `crm.enquiry.reassign`
CRM business-role set was also stabilized around:
- `sales_manager`
- `sales`
- `sales_support`
- `department_manager`
- `top_manager`
Legacy IT-CENTER business roles were removed from active runtime paths and defaults.
## 6. UI Added
Enquiry detail now supports:
- `Assign Sales`
- `Reassign Sales`
- assigned sales metadata display
Enquiry list now shows:
- `Assigned Sales` column
Assignment dialog now includes:
- `Sales User`
- `Remark`
The sales-user dropdown is populated from organization memberships instead of hardcoded values.
## 7. Audit Integration
Assignment writes audit logs under:
- `entityType = crm_enquiry`
Actions added:
- `assign`
- `reassign`
Audit payload shape:
- `beforeData.oldAssignedToUserId`
- `afterData.newAssignedToUserId`
- `afterData.remark`
## 8. Membership / Permission Stabilization
Workspace membership management now uses CRM-aligned business roles instead of the old template roles.
Updated areas:
- user API types and validation
- membership defaults
- organization-management fallbacks
- auth-context business-role fallback
## 9. Remaining Risks
- current `sales` access still operates at organization scope; there is not yet a server-enforced “own or assigned only” data policy
- old database rows that still carry removed legacy business-role strings may need a one-time data backfill if they exist in a real environment
- assignable-user filtering currently accepts organization admins plus sales-oriented roles; if future CRM staffing rules become stricter, that policy should move into a dedicated assignment rule layer
## 10. Verification
- `npx tsc --noEmit`
- `npx oxlint` on changed Task D.1 files

View File

@@ -0,0 +1,122 @@
# Task H.1: PDF Generation Stabilization
## 1. Files Added
- `scripts/seed-task-h1-fixture.js`
- `scripts/verify-task-h1.js`
- `docs/adr/0009-approved-document-storage-lifecycle.md`
## 2. Files Modified
- `package.json`
- `docs/implementation/technical-debt.md`
Task H.1 builds on the Task H PDF pipeline and focuses on validation, fixture support, documentation, and storage-lifecycle clarification.
## 3. Smoke Test Record Used
Added a dev-only fixture script for a real quotation scenario:
- approved fixture code: `QT-TASK-H1-APPROVED`
- draft fixture code: `QT-TASK-H1-DRAFT`
- customer fixture code: `CUS-TASK-H1`
Fixture script provisions:
- organization-scoped customer and contact
- approved quotation with item, customer role, scope/exclusion/payment topics, and completed approval history
- draft quotation for negative approved-PDF validation
- test users for permission checks
## 4. PDF APIs Verified
Task H.1 verification script covers:
- `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 checks included:
- preview responds as inline PDF
- download responds as attachment PDF
- approved-PDF POST succeeds for approved quotation
- approved-PDF GET returns persisted artifact
- non-approved quotation cannot generate approved PDF
## 5. Persistence Verified
Task H.1 verification targets:
- `approvedPdfUrl`
- `approvedSnapshot`
- `approvedTemplateVersionId`
- quotation attachment metadata row
- audit log row
- generated file under `public/generated/quotations/<organizationId>/`
## 6. Permission Verified
Fixture and verification support explicit checks for:
- `super_admin`
- admin workspace member
- regular user with PDF permissions
- regular user without PDF permissions
Permissions covered:
- `crm.quotation.pdf.preview`
- `crm.quotation.pdf.download`
- `crm.quotation.pdf.generate_approved`
## 7. Error Handling / Hardening
Task H.1 centered hardening around safe operational verification rather than a large refactor.
The main stabilization additions were:
- reusable dev fixture creation
- repeatable HTTP verification script
- clearer storage lifecycle documentation
- explicit debt tracking for fonts, local storage, and approved-artifact policy
## 8. ADR / Technical Debt Updated
Added ADR:
- `docs/adr/0009-approved-document-storage-lifecycle.md`
Updated debt notes in:
- `docs/implementation/technical-debt.md`
Tracked concerns include:
- font compatibility fallback
- local filesystem storage limitation
- approved artifact immutability policy
- post-approval automation gap
- missing CI-backed PDF E2E coverage
## 9. Remaining Risks
- approved PDF storage still uses local public filesystem semantics and is not production-grade object storage
- approved artifact lifecycle is still not immutable by policy
- PDF verification is script-based and not yet part of CI
- task fixture scripts currently depend on a prepared local environment and seeded master-option / approval data
## 10. Task I Readiness
Task H.1 leaves the PDF flow better prepared for Task I by providing:
- a repeatable quotation fixture path
- a repeatable verification script
- explicit storage-lifecycle decision documentation
- visible technical-debt boundaries for future production hardening
## 11. Verification Commands
- `npm run seed:task-h1-fixture`
- `npm run verify:task-h1`

View File

@@ -0,0 +1,155 @@
# Task I: Storage Abstraction and Approved Artifact Lifecycle
## 1. Files Added
- `src/features/foundation/storage/types.ts`
- `src/features/foundation/storage/service.ts`
- `src/features/foundation/storage/server/local-provider.ts`
- `src/features/foundation/storage/server/s3-provider.ts`
- `src/features/foundation/storage/server/provider.ts`
- `src/features/foundation/storage/server/checksum.ts`
- `src/features/foundation/document-artifact/server/service.ts`
- `src/app/api/crm/document-artifacts/[id]/download/route.ts`
- `drizzle/0009_lazy_shard.sql`
- `drizzle/meta/0009_snapshot.json`
## 2. Files Modified
- `package.json`
- `package-lock.json`
- `src/db/schema.ts`
- `src/lib/auth/rbac.ts`
- `src/features/foundation/pdf-generator/server/service.ts`
- `src/features/foundation/approval/server/service.ts`
- `src/features/crm/quotations/api/types.ts`
- `src/features/crm/quotations/server/service.ts`
- `src/features/crm/quotations/components/quotation-detail.tsx`
- `src/app/api/crm/quotations/[id]/approved-pdf/route.ts`
- `scripts/seed-task-h1-fixture.js`
- `docs/implementation/technical-debt.md`
- `docs/adr/0009-approved-document-storage-lifecycle.md`
## 3. Storage Provider Added
Added a storage foundation under `src/features/foundation/storage/**`.
Providers:
- local provider for development
- S3 / MinIO-compatible provider for production-oriented storage
Configuration supported:
- `STORAGE_PROVIDER`
- `LOCAL_STORAGE_ROOT`
- `S3_ENDPOINT`
- `S3_REGION`
- `S3_BUCKET`
- `S3_ACCESS_KEY_ID`
- `S3_SECRET_ACCESS_KEY`
- `S3_FORCE_PATH_STYLE`
## 4. Artifact Schema Added
Added `crm_document_artifacts` with:
- organization/entity identity
- document and artifact types
- template version reference
- storage provider and storage key
- file metadata
- checksum
- lifecycle status
- generated / locked / voided metadata
- JSON metadata payload
Added `approved_artifact_id` to `crm_quotations`.
## 5. Approved PDF Refactor
Approved quotation PDFs now:
- write through `StorageProvider.putObject()`
- persist checksum via SHA-256
- create a `crm_document_artifacts` row
- lock the artifact after generation
- update quotation `approvedArtifactId`
- keep `approvedPdfUrl` as compatibility reference in `artifact:<artifactId>` form
Rules enforced:
- only approved quotations can generate approved PDF
- locked approved artifacts cannot be overwritten
- existing active approved artifact blocks silent regeneration
## 6. Secure Download Added
Added secure artifact route:
- `GET /api/crm/document-artifacts/[id]/download`
Updated quotation approved-PDF flow so secure reads resolve artifact metadata from the database and fetch bytes through the storage provider instead of treating public file paths as source of truth.
## 7. UI Updated
Quotation detail now shows approved artifact metadata when available:
- file name
- generated by
- generated at
- checksum short form
- locked status badge
It also shows a legacy warning when a quotation still references old `/generated/...` storage without an artifact record.
## 8. Permissions Added
- `crm.document_artifact.read`
- `crm.document_artifact.download`
- `crm.document_artifact.void`
Defaults were added alongside the existing quotation PDF permissions in RBAC role derivation.
## 9. Audit Integration
Artifact service now audits:
- `create`
- `lock`
- `void`
- `download`
Entity type:
- `crm_document_artifact`
## 10. Compatibility Notes
Compatibility behavior now supports:
- `approvedArtifactId` when present
- `approvedPdfUrl = artifact:<artifactId>` during migration
- legacy `/generated/...` approved PDF paths with warning fallback
Task I intentionally does not bulk-migrate legacy files yet.
## 11. Remaining Risks
- artifact void/regeneration still has no admin UI workflow
- legacy approved PDFs still need one-time migration into artifact storage
- signed-URL delivery is not enabled yet; downloads are still server-streamed
- storage-provider health and bucket policy checks are still operational concerns outside the app
## 12. Task J Readiness
Task I leaves the app ready for:
- richer artifact lifecycle tooling
- legacy-file migration command
- object-storage rollout beyond local development
- future secure delivery strategies such as signed URLs
## 13. Verification
- `npx tsc --noEmit`
- `npm run gen`

View File

@@ -3,136 +3,242 @@
## After Task D ## After Task D
### Database foreign keys ### Database foreign keys
Customer, contact, and enquiry relationships are still enforced mainly in application logic. Customer, contact, and enquiry relationships are still enforced mainly in application logic.
Future: Future:
- add FK constraints after the CRM schema settles - add FK constraints after the CRM schema settles
- review cascade behavior before enabling hard relational enforcement - review cascade behavior before enabling hard relational enforcement
### Date input UX ### Date input UX
Several forms still use plain `YYYY-MM-DD` text/date inputs. Several forms still use plain `YYYY-MM-DD` text/date inputs.
Future: Future:
- move to a shared date picker pattern - move to a shared date picker pattern
- standardize timezone handling for CRM forms - standardize timezone handling for CRM forms
### Related enquiry pagination ### Related enquiry pagination
Customer detail can show related enquiries, but that view still has no dedicated pagination strategy. Customer detail can show related enquiries, but that view still has no dedicated pagination strategy.
Future: Future:
- add paging once real data volume grows - add paging once real data volume grows
### Follow-up activity timeline ### Follow-up activity timeline
Activity history still relies heavily on audit payloads instead of a curated domain timeline. Activity history still relies heavily on audit payloads instead of a curated domain timeline.
Future: Future:
- build a combined activity view/service for CRM interactions - build a combined activity view/service for CRM interactions
## After Task D.1
### Enquiry Own / Assigned Scope
Current:
sales role ยังอ่าน enquiry ในระดับ organization scope
Future:
เพิ่ม server-side policy:
- sales เห็นเฉพาะ enquiry ที่ตัวเองสร้าง
- หรือ enquiry ที่ assigned ให้ตัวเอง
- sales_manager เห็นทั้งทีม
Priority:
High
### Legacy Business Role Backfill
Current:
ระบบ runtime ใช้ CRM businessRole แล้ว แต่ DB เก่าอาจยังมี role จาก IT-CENTER
Future:
ทำ one-time migration/backfill script สำหรับ memberships.businessRole
Priority:
Medium
## After Task E ## After Task E
### Mixed tax rollup ### Mixed tax rollup
Quotation items support per-line tax input, but summary calculation still assumes a header-centric tax model. Quotation items support per-line tax input, but summary calculation still assumes a header-centric tax model.
Future: Future:
- support mixed-tax aggregation from actual item rows - support mixed-tax aggregation from actual item rows
- align document totals and reporting with per-line tax logic - align document totals and reporting with per-line tax logic
### Attachment storage abstraction ### 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. Quotation attachments started as metadata-only and approved PDFs now write to local disk, but there is still no real storage abstraction.
Future: Future:
- introduce a provider abstraction - introduce a provider abstraction
- support upload/download lifecycle consistently across manual attachments and generated artifacts - support upload/download lifecycle consistently across manual attachments and generated artifacts
- define migration strategy for existing local/public paths - define migration strategy for existing local/public paths
### Revision snapshot coverage ### Revision snapshot coverage
Revision copy currently focuses on quotation header, items, customers, and topics. Revision copy currently focuses on quotation header, items, customers, and topics.
Future: Future:
- decide whether follow-ups and attachments belong in revision snapshots - decide whether follow-ups and attachments belong in revision snapshots
- define immutable vs mutable child-data behavior across revisions - define immutable vs mutable child-data behavior across revisions
### CRM foreign keys ### CRM foreign keys
Core CRM tables still depend on organization-scoped validation in service code. Core CRM tables still depend on organization-scoped validation in service code.
Future: Future:
- add FK constraints after schema churn slows down - add FK constraints after schema churn slows down
- review delete and soft-delete interactions first - review delete and soft-delete interactions first
## After Task F ## After Task F
### Permission sync for existing memberships ### Permission sync for existing memberships
New permissions are defined in code, but old membership rows may still hold stale permission arrays. New permissions are defined in code, but old membership rows may still hold stale permission arrays.
Future: Future:
- add a permission sync/backfill command - add a permission sync/backfill command
- provide admin tooling for role and permission maintenance - provide admin tooling for role and permission maintenance
### Approval workflow configuration UI ### Approval workflow configuration UI
Approval workflows are seeded and usable, but still not configurable from the dashboard. Approval workflows are seeded and usable, but still not configurable from the dashboard.
Future: Future:
- build workflow setup screens - build workflow setup screens
- add step editing and role mapping management - add step editing and role mapping management
### Approval notifications ### Approval notifications
Approval flow has no queue-backed notification channel yet. Approval flow has no queue-backed notification channel yet.
Future: Future:
- add notification jobs - add notification jobs
- support reminders and escalation policy - 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 manageable from UI. Template mappings are seeded and readable, but not manageable from UI.
Future: Future:
- add mapping CRUD for admins - add mapping CRUD for admins
- support table column editing - support table column editing
- add draft/publish template workflow - add draft/publish template workflow
### Product-specific template resolution ### Product-specific template resolution
Quotation document template resolution still defaults to a coarse product selection strategy. Quotation document template resolution still defaults to a coarse product selection strategy.
Future: Future:
- define clearer rules for crane, dock door, solar, and service-specific templates - define clearer rules for crane, dock door, solar, and service-specific templates
## After Task H / H.1 ## After Task H / H.1
### Font compatibility fallback ### 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. 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:
- keep validating Thai text metrics against designer output - keep validating Thai text metrics against designer output
- remove fallback normalization only after render parity is proven stable - remove fallback normalization only after render parity is proven stable
### Local filesystem storage ### Local filesystem storage
Approved PDFs currently persist under `public/generated/quotations/<organizationId>/`. Approved PDFs currently persist under `public/generated/quotations/<organizationId>/`.
Future: Future:
- replace local public storage for production with object storage - replace local public storage for production with object storage
- add signed/private delivery where required - add signed/private delivery where required
- define backup and cleanup expectations - define backup and cleanup expectations
### Approved artifact immutability ### Approved artifact immutability
Approved PDF persistence exists, but the lifecycle is not immutable by policy yet. Approved PDF persistence exists, but the lifecycle is not immutable by policy yet.
Future: Future:
- define whether regeneration is allowed after approval - define whether regeneration is allowed after approval
- version approved artifacts explicitly if regeneration must remain possible - version approved artifacts explicitly if regeneration must remain possible
- document which snapshot fields are contractual records - 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:
- add a non-blocking auto-generation hook after final approval - add a non-blocking auto-generation hook after final approval
- move heavy generation/retry behavior to a background job when needed - move heavy generation/retry behavior to a background job when needed
### PDF pipeline test coverage ### 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. Task H.1 added dev fixture and HTTP verification scripts, but there is still no CI-backed E2E coverage for the PDF pipeline.
Future: Future:
- promote fixture verification into automated test coverage - promote fixture verification into automated test coverage
- add regression checks for template mappings, storage persistence, and permission enforcement - add regression checks for template mappings, storage persistence, and permission enforcement
## After Task I
### Storage provider production rollout
Task I introduced a storage provider abstraction with local and S3/MinIO-compatible implementations, but the production rollout still depends on environment configuration and infrastructure ownership outside the app.
Future:
- validate real bucket policies, credentials rotation, and environment separation
- add operational runbooks for provider misconfiguration and storage outage handling
- add smoke checks for startup-time storage provider health
### Legacy approved PDF migration
Task I keeps compatibility for legacy `public/generated/...` approved PDFs, but those legacy files are still outside the new artifact source-of-truth model.
Future:
- add a one-time migration script from legacy local paths into `crm_document_artifacts`
- backfill `approvedArtifactId` where possible
- remove legacy compatibility once migration is complete and verified
### Artifact lifecycle policy depth
Approved artifacts are now immutable-first and locked after generation, but void/regeneration policy is still service-level rather than a full operator workflow.
Future:
- add explicit admin tooling for artifact void and replacement flows
- define whether artifact voiding also clears or supersedes quotation-facing references automatically
- document retention expectations for voided objects in object storage
### Secure artifact delivery model
Approved-PDF viewing now goes through secure server routes and storage-backed reads, but signed-url/offload behavior is not yet used.
Future:
- decide when direct signed URLs are acceptable versus mandatory server streaming
- add response caching strategy for large artifact downloads
- evaluate optional download-audit sampling if volume grows

View File

@@ -0,0 +1,30 @@
CREATE TABLE "crm_document_artifacts" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"document_type" text NOT NULL,
"artifact_type" text NOT NULL,
"template_version_id" text,
"storage_provider" text NOT NULL,
"storage_key" text NOT NULL,
"file_name" text NOT NULL,
"content_type" text NOT NULL,
"file_size" integer,
"checksum" text,
"status" text DEFAULT 'active' NOT NULL,
"generated_by" text NOT NULL,
"generated_at" timestamp with time zone DEFAULT now() NOT NULL,
"locked_at" timestamp with time zone,
"locked_by" text,
"voided_at" timestamp with time zone,
"voided_by" text,
"void_reason" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "memberships" ALTER COLUMN "business_role" SET DEFAULT 'sales_support';--> statement-breakpoint
ALTER TABLE "crm_quotations" ADD COLUMN "approved_artifact_id" text;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1781595962545, "when": 1781595962545,
"tag": "0008_clean_justin_hammer", "tag": "0008_clean_justin_hammer",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1781603421415,
"tag": "0009_lazy_shard",
"breakpoints": true
} }
] ]
} }

681
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "alla-allaos-fullstack", "name": "alla-allaos-fullstack",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1069.0",
"@aws-sdk/s3-request-presigner": "^3.1069.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -143,6 +145,453 @@
} }
} }
}, },
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@aws-crypto/crc32c": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
"integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/sha1-browser": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
"integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/supports-web-crypto": "^5.2.0",
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"@aws-sdk/util-locate-window": "^3.0.0",
"@smithy/util-utf8": "^2.0.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/sha256-browser": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
"integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
"@aws-crypto/supports-web-crypto": "^5.2.0",
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"@aws-sdk/util-locate-window": "^3.0.0",
"@smithy/util-utf8": "^2.0.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/sha256-js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
"integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@aws-crypto/supports-web-crypto": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
"integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/util": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.222.0",
"@smithy/util-utf8": "^2.0.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.6.tgz",
"integrity": "sha512-RMCrCteiUwYTEv2G9zfP/BEuKHv57665vVieJyp9cf8VgilWxP/KrWVtMdfdDlIH8nFhvu3rIMc29z3ebGEZ1w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@aws-crypto/crc32c": "5.2.0",
"@aws-crypto/util": "5.2.0",
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1069.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1069.0.tgz",
"integrity": "sha512-zkwCW4D88foQ0YHyjVyFhDOIWG+IWiTGXYg8+kUgH6M19son3OsZLvioZGyZdNtlRgLNYfQGBjfArSJOYYt2QQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/credential-provider-node": "^3.972.56",
"@aws-sdk/middleware-flexible-checksums": "^3.974.31",
"@aws-sdk/middleware-sdk-s3": "^3.972.52",
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/fetch-http-handler": "^5.4.6",
"@smithy/node-http-handler": "^4.7.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.974.21",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.21.tgz",
"integrity": "sha512-P5JAHvn4dTi96UsAGS67LVOqqpUNNRhnfFXqzCYtdBIGZtqBue4CXvRr9YenOO7PALj/Pn8uuyw53FBCiCYw8w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.13",
"@aws-sdk/xml-builder": "^3.972.30",
"@aws/lambda-invoke-store": "^0.2.2",
"@smithy/core": "^3.24.6",
"@smithy/signature-v4": "^5.4.6",
"@smithy/types": "^4.14.3",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.47.tgz",
"integrity": "sha512-3YoPwJczcc+MtX2xxXaYaOOWO6xKUJr1ZIIDIFuninr51BYONVVcF/CP8K2xfVRC/PztJjqKWxNGFH7BWQAw1Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.49",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.49.tgz",
"integrity": "sha512-2UtGUPy+x3lqyceHrtC1uEuVxBZbDalPF6KAFqBwYgm4edWdBrZKNnCqzDs7KynWUvEC6mrR+ojRk+ZgQz9C2w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/fetch-http-handler": "^5.4.6",
"@smithy/node-http-handler": "^4.7.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.972.54",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.54.tgz",
"integrity": "sha512-Hx4gO4YRjFwitf3MVl3cDwYe1aryJthC4txVl9b+JAURovA50M2ywf9r8j1E/Q6SCTPT4qQpjOAbKYIC9CG+Vw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/credential-provider-env": "^3.972.47",
"@aws-sdk/credential-provider-http": "^3.972.49",
"@aws-sdk/credential-provider-login": "^3.972.53",
"@aws-sdk/credential-provider-process": "^3.972.47",
"@aws-sdk/credential-provider-sso": "^3.972.53",
"@aws-sdk/credential-provider-web-identity": "^3.972.53",
"@aws-sdk/nested-clients": "^3.997.21",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/credential-provider-imds": "^4.3.7",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.53",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.53.tgz",
"integrity": "sha512-+71sluhkgPqdhbbD3UDwUpj24GCkng9HQx6z7qoBFb8dwkF4ktpOcVKDeHpgg8PvBgLYwAnUYLTEGRC/PniCiQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/nested-clients": "^3.997.21",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.56",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.56.tgz",
"integrity": "sha512-iI+4o0dvQQ4NHel4FMDiFy5q2gaU/ryLK3niOsoPccAt9WLFRkV4XTYPWRr9XvmBUqEzXG73S4p/8gm0Lu/W3A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.47",
"@aws-sdk/credential-provider-http": "^3.972.49",
"@aws-sdk/credential-provider-ini": "^3.972.54",
"@aws-sdk/credential-provider-process": "^3.972.47",
"@aws-sdk/credential-provider-sso": "^3.972.53",
"@aws-sdk/credential-provider-web-identity": "^3.972.53",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/credential-provider-imds": "^4.3.7",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.47.tgz",
"integrity": "sha512-tAizPm9IFo/PHn06c+LQJlzfY2AGOlyF0CUljFejrU6LcZBjnk8pmbZK3/xoIDdnIzjEdbClfvY3mXfr818ZEg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.972.53",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.53.tgz",
"integrity": "sha512-pUXE3fu4tfEDV8BksIgf4dXvuIH10FhwHMl/wu8rBD5T1sMpryQWFVitH3kdPS90wlgrGYJQ/meQTSPacyZfeg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/nested-clients": "^3.997.21",
"@aws-sdk/token-providers": "3.1069.0",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.53",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.53.tgz",
"integrity": "sha512-JmMGlhVvSj8uSG9CpeDkJAXT35H89tc6v84iMgEIE75q4yp1MKVVKvopv6Gg28HJIR7hMNkojRF8H2m5W44wyg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/nested-clients": "^3.997.21",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-flexible-checksums": {
"version": "3.974.31",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.31.tgz",
"integrity": "sha512-Yzj6NRYVZdBaCp7o1BwHGyeDBfixdeToLIAMprshIITEdl9wKVSiidVOfeaiH8FyeC1hBmBfDZFvs/aH1Y3xpw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.6",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.52",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.52.tgz",
"integrity": "sha512-rerjP08onRqkBh0AcCqip6GkKvESapmLoTgi1xysZ4C6a1xMrIMtTBcEbUb6EY71oeajnigeUD4KwZjtIO+aWQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.21",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.21.tgz",
"integrity": "sha512-eC7Vl7Qom/BGhZjG9GEqPwdQ/fk45hg1t5LP4EUxG5d1fdshLbaxCiwh/tszUzDX/4mW40mu2QsbeJJRPBbqUw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/fetch-http-handler": "^5.4.6",
"@smithy/node-http-handler": "^4.7.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.1069.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1069.0.tgz",
"integrity": "sha512-/s8zMe/C4bLLilx8Jlh3neN2sbaGmQTdi5dxu/GG1D9wlg+3MDemqyzYy9Gi1W2m/iD2+qf6kEpNk97qEEBcZA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz",
"integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.13",
"@smithy/signature-v4": "^5.4.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1069.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1069.0.tgz",
"integrity": "sha512-ks4X+kngC3PA5howV7Qu1TgG4bfC4jPykKdvw3nmBSXR9yZxRJouBholFSNQ5kY3L+Fgwyw+LCjzQmNi+KR91g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.21",
"@aws-sdk/nested-clients": "^3.997.21",
"@aws-sdk/types": "^3.973.13",
"@smithy/core": "^3.24.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.973.13",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz",
"integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/util-locate-window": {
"version": "3.965.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz",
"integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.30",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.30.tgz",
"integrity": "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.14.3",
"fast-xml-parser": "5.7.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
"integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -2044,6 +2493,18 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@nodable/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@opentelemetry/api": { "node_modules/@opentelemetry/api": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
@@ -5581,6 +6042,126 @@
"webpack": ">=5.0.0" "webpack": ">=5.0.0"
} }
}, },
"node_modules/@smithy/core": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.0.tgz",
"integrity": "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.0.tgz",
"integrity": "sha512-pPQmNdEvMJttv9z2kdYxoui83p/nr32zjMf0aMfmzmGmFEgKXUfy0vXiNg0fx4R5XLQzmJBLM9Wg0guEq2/q8A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.25.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.0.tgz",
"integrity": "sha512-OG8kBYAgX7lf32+xLzgirvuLffn1KNoszaSiButt45i2cRa5irk8LQXLYQ5Smij1SBTN4KMNcBsRwRrLPfIGyA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.25.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/is-array-buffer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.0.tgz",
"integrity": "sha512-Mq7TNt/VhlEWiYRLQGpzUWeUxh899UGpjKh7Ru0WVIDIjnE+cTRAn0NYlFQ6bWfsQnKnpCbWJj86HzmcG0qEdg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.25.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.0.tgz",
"integrity": "sha512-vW6UdK7e7gV2wU/tXRsPq4pMQMusb8VymdVOyIFNA1FtyRmEClRFkYDtYI8UcO/HM0wK3qqjvvQs3HOlbgMbdg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.25.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.15.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz",
"integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-buffer-from": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@smithy/util-utf8": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -6268,6 +6849,18 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/anynum": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz",
"integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/aria-hidden": { "node_modules/aria-hidden": {
"version": "1.2.6", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -6339,6 +6932,12 @@
"bcrypt": "bin/bcrypt" "bcrypt": "bin/bcrypt"
} }
}, },
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
@@ -7159,6 +7758,43 @@
"integrity": "sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==", "integrity": "sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.7",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -9007,6 +9643,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -10014,6 +10665,21 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strnum": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz",
"integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.0"
}
},
"node_modules/styled-jsx": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -10869,6 +11535,21 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -22,6 +22,8 @@
"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": {
"@aws-sdk/client-s3": "^3.1069.0",
"@aws-sdk/s3-request-presigner": "^3.1069.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",

475
plans/task-i.md Normal file
View File

@@ -0,0 +1,475 @@
# Task I: Storage Abstraction + Approved Artifact Lifecycle
## ALLA OS CRM vNext
คุณคือ Senior Full-stack Engineer
ให้ implement storage abstraction และ approved artifact lifecycle สำหรับ Quotation PDF โดยต่อยอดจาก Task H และ H.1
---
## Must Read
```txt
docs/implementation/technical-debt.md
docs/adr/0008-attachment-storage-strategy.md
docs/adr/0009-approved-document-storage-lifecycle.md
src/features/foundation/pdf-generator/**
src/features/foundation/document-template/**
src/features/crm/quotations/document/**
src/features/crm/quotations/**
src/app/api/crm/quotations/[id]/approved-pdf/route.ts
src/db/schema.ts
```
---
# Goal
ทำให้ PDF storage พร้อม production มากขึ้น
จากเดิม:
```txt
public/generated/quotations/<organizationId>/
```
ไปเป็น:
```txt
Storage Provider Interface
Local Provider for dev
S3 / MinIO compatible provider for production
Secure download route
Immutable approved artifact lifecycle
```
---
# Scope I1: Storage Provider Interface
สร้าง foundation module:
```txt
src/features/foundation/storage/**
```
Files:
```txt
types.ts
service.ts
server/local-provider.ts
server/s3-provider.ts
server/provider.ts
```
Interface:
```ts
export interface StorageProvider {
putObject(input: PutObjectInput): Promise<StoredObject>
getObject(input: GetObjectInput): Promise<StoredObjectStream>
deleteObject(input: DeleteObjectInput): Promise<void>
objectExists(input: ObjectExistsInput): Promise<boolean>
getSignedUrl?(input: GetSignedUrlInput): Promise<string>
}
```
Types:
```ts
PutObjectInput
GetObjectInput
DeleteObjectInput
ObjectExistsInput
GetSignedUrlInput
StoredObject
StoredObjectStream
```
Rules:
* service ต้อง server-only
* client ห้ามเข้าถึง provider โดยตรง
* storage key ต้อง organization-scoped
* ห้ามใช้ raw public path เป็น source of truth
---
# Scope I2: Storage Configuration
รองรับ env:
```txt
STORAGE_PROVIDER=local | s3
LOCAL_STORAGE_ROOT=storage
S3_ENDPOINT=
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=true
```
Rules:
* local ใช้ได้ใน dev
* s3 provider ต้อง compatible กับ MinIO/S3
* ถ้า config ไม่ครบ ให้ throw error ชัดเจน
* อย่า hardcode bucket/path
---
# Scope I3: Artifact Schema
เพิ่ม table ถ้ายังไม่มี:
```txt
crm_document_artifacts
```
Fields:
```txt
id
organizationId
entityType
entityId
documentType
artifactType
templateVersionId
storageProvider
storageKey
fileName
contentType
fileSize
checksum
status
generatedBy
generatedAt
lockedAt
lockedBy
voidedAt
voidedBy
voidReason
metadata
createdAt
updatedAt
deletedAt
```
artifactType:
```txt
preview
approved_pdf
export
```
status:
```txt
active
locked
voided
deleted
```
Rules:
* approved_pdf ต้อง lock หลัง generate
* approved_pdf ห้าม overwrite
* ถ้าต้อง regenerate ให้สร้าง artifact ใหม่หรือ void artifact เดิมก่อนตาม policy
* organizationId required
---
# Scope I4: Checksum
เพิ่ม utility:
```txt
src/features/foundation/storage/server/checksum.ts
```
รองรับ:
```ts
sha256(buffer)
```
ใช้บันทึก checksum ของ PDF artifact
---
# Scope I5: Approved PDF Storage Refactor
ปรับ `generateApprovedQuotationPdf()` จากการเขียน:
```txt
public/generated/...
```
เป็น:
```txt
StorageProvider.putObject()
crm_document_artifacts insert
quotation.approvedPdfUrl update เป็น secure reference
```
Recommended `approvedPdfUrl`:
```txt
artifact:<artifactId>
```
หรือถ้ามี field ใหม่:
```txt
approvedArtifactId
```
ถ้าต้องเพิ่ม field ใน quotation:
```txt
approvedArtifactId
```
ให้เพิ่มแบบระวัง
Rules:
* approved PDF generate ได้เฉพาะ quotation.status = approved
* ถ้ามี locked approved artifact อยู่แล้ว ให้ไม่ overwrite
* ถ้าต้อง regenerate ให้ return error พร้อมข้อความชัดเจน
* create attachment metadata ให้ชี้ storageKey/artifactId
* audit generate approved PDF
---
# Scope I6: Secure Download Route
ปรับ:
```txt
GET /api/crm/quotations/[id]/approved-pdf
```
ให้:
* อ่าน artifact จาก database
* validate organizationId
* validate permission
* ดึงไฟล์ผ่าน StorageProvider.getObject()
* stream PDF กลับ
* ไม่ใช้ public file path ตรง ๆ
เพิ่ม route generic ได้ถ้าเหมาะสม:
```txt
GET /api/crm/document-artifacts/[id]/download
```
Rules:
* ห้าม expose storageKey ถ้าไม่จำเป็น
* ห้ามให้ user เดา URL แล้วโหลดได้
* permission guard ต้องอยู่ server route
* approved PDF download ควร audit ได้ ถ้าทำไม่ใหญ่เกินไป
---
# Scope I7: Artifact Lifecycle
Implement service:
```txt
src/features/foundation/document-artifact/server/service.ts
```
Functions:
```ts
createArtifact()
getArtifact()
getActiveArtifactForEntity()
lockArtifact()
voidArtifact()
deleteArtifactMetadataOnly()
downloadArtifact()
```
Rules:
* `locked` artifact ห้ามแก้ storageKey/fileSize/checksum
* `voided` artifact ยังเก็บ metadata
* delete จริงยังไม่ต้องลบ object storage ถ้าไม่จำเป็น
* approved PDF lifecycle ต้อง immutable-first
---
# Scope I8: UI Integration
Quotation detail:
* View Approved PDF ใช้ secure download route
* ถ้ามี approved artifact แสดง:
* fileName
* generatedAt
* generatedBy
* checksum short
* locked badge
* ถ้ายังไม่มี artifact แต่มี old approvedPdfUrl จาก Task H ให้แสดง migration warning
---
# Scope I9: Migration Compatibility
ถ้ามี approvedPdfUrl เดิมแบบ:
```txt
/generated/...
```
ให้ไม่ลบทิ้งทันที
เพิ่ม compatibility:
* ถ้า approvedPdfUrl เป็น artifact reference → ใช้ artifact
* ถ้าเป็น legacy local path → แสดง legacy warning
* future migration script ค่อย migrate legacy file
---
# Scope I10: Documentation
Update:
```txt
docs/implementation/technical-debt.md
docs/adr/0009-approved-document-storage-lifecycle.md
```
เพิ่ม:
```txt
Storage provider abstraction
Local vs S3/MinIO policy
Immutable artifact policy
Legacy public/generated migration
Download security model
```
---
# Permissions
เพิ่มถ้ายังไม่มี:
```txt
crm.document_artifact.read
crm.document_artifact.download
crm.document_artifact.void
```
---
# Audit
Audit required:
```txt
crm_document_artifact
```
Actions:
```txt
create
lock
void
download
```
ถ้า download audit ทำให้ระบบหนัก ให้ทำ optional toggle ได้
---
# ห้ามทำ
```txt
Report
Dashboard KPI
Notification
Template Designer
Mapping Editor
Full object-storage admin UI
Bulk migration of legacy files
```
---
# Output
หลังทำเสร็จ สรุป:
1. Files Added
2. Files Modified
3. Storage Provider Added
4. Artifact Schema Added
5. Approved PDF Refactor
6. Secure Download Added
7. UI Updated
8. Permissions Added
9. Audit Integration
10. Compatibility Notes
11. Remaining Risks
12. Task J Readiness
---
# Definition of Done
Task I ผ่านเมื่อ:
✔ storage provider interface พร้อม
✔ local provider พร้อม
✔ s3/minio provider skeleton พร้อม
✔ approved PDF ไม่เขียนลง public/generated เป็น source of truth แล้ว
✔ approved artifact ถูกบันทึกใน crm_document_artifacts
✔ approved artifact locked/immutable
✔ approved PDF download ผ่าน secure route
✔ permission guard download/generate ครบ
✔ checksum ถูกบันทึก
✔ quotation UI แสดง artifact metadata
✔ legacy approvedPdfUrl compatibility มี
✔ technical debt / ADR updated
✔ ยังไม่ทำ report/dashboard/notification

View File

@@ -21,7 +21,9 @@ const PDF_PERMISSIONS = [
'crm.quotation.document.preview', 'crm.quotation.document.preview',
'crm.quotation.pdf.preview', 'crm.quotation.pdf.preview',
'crm.quotation.pdf.download', 'crm.quotation.pdf.download',
'crm.quotation.pdf.generate_approved' 'crm.quotation.pdf.generate_approved',
'crm.document_artifact.read',
'crm.document_artifact.download'
]; ];
function loadEnvFile(filePath) { function loadEnvFile(filePath) {
@@ -886,7 +888,7 @@ async function main() {
const users = { const users = {
admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', { admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', {
role: 'admin', role: 'admin',
businessRole: 'it_admin', businessRole: 'sales_manager',
permissions: [...PDF_PERMISSIONS, 'users:manage'] permissions: [...PDF_PERMISSIONS, 'users:manage']
}), }),
pdfUser: await ensureUser( pdfUser: await ensureUser(
@@ -897,13 +899,13 @@ async function main() {
'Task H.1 PDF User', 'Task H.1 PDF User',
{ {
role: 'user', role: 'user',
businessRole: 'viewer', businessRole: 'sales_support',
permissions: PDF_PERMISSIONS permissions: PDF_PERMISSIONS
} }
), ),
viewer: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.viewer, 'Task H.1 Viewer', { viewer: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.viewer, 'Task H.1 Viewer', {
role: 'user', role: 'user',
businessRole: 'viewer', businessRole: 'sales_support',
permissions: ['crm.quotation.read'] permissions: ['crm.quotation.read']
}) })
}; };

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { downloadArtifact } from '@/features/foundation/document-artifact/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentArtifactDownload
});
const { object } = await downloadArtifact({
artifactId: id,
organizationId: organization.id,
userId: session.user.id,
auditDownload: true
});
return new NextResponse(object.body, {
headers: {
'Content-Type': object.contentType,
'Content-Disposition': `inline; filename="${object.fileName || 'artifact'}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to download document artifact' }, { status: 500 });
}
}

View File

@@ -15,10 +15,13 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) { export async function GET(_request: NextRequest, { params }: Params) {
try { try {
const { id } = await params; const { id } = await params;
const { organization } = await requireOrganizationAccess({ const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfDownload permission: PERMISSIONS.crmQuotationPdfDownload
}); });
const pdf = await getStoredApprovedQuotationPdf(id, organization.id); const pdf = await getStoredApprovedQuotationPdf(id, organization.id, {
userId: session.user.id,
auditDownload: true
});
return new NextResponse(pdf.buffer, { return new NextResponse(pdf.buffer, {
headers: { headers: {
@@ -52,6 +55,7 @@ export async function POST(_request: NextRequest, { params }: Params) {
action: 'generate_approved', action: 'generate_approved',
beforeData: before, beforeData: before,
afterData: { afterData: {
approvedArtifactId: after.approvedArtifactId,
approvedPdfUrl: after.approvedPdfUrl, approvedPdfUrl: after.approvedPdfUrl,
approvedTemplateVersionId: after.approvedTemplateVersionId approvedTemplateVersionId: after.approvedTemplateVersionId
} }

View File

@@ -289,6 +289,7 @@ export const crmQuotations = pgTable(
sentAt: timestamp('sent_at', { withTimezone: true }), sentAt: timestamp('sent_at', { withTimezone: true }),
sentVia: text('sent_via'), sentVia: text('sent_via'),
approvedAt: timestamp('approved_at', { withTimezone: true }), approvedAt: timestamp('approved_at', { withTimezone: true }),
approvedArtifactId: text('approved_artifact_id'),
approvedPdfUrl: text('approved_pdf_url'), approvedPdfUrl: text('approved_pdf_url'),
approvedSnapshot: jsonb('approved_snapshot'), approvedSnapshot: jsonb('approved_snapshot'),
approvedTemplateVersionId: text('approved_template_version_id'), approvedTemplateVersionId: text('approved_template_version_id'),
@@ -401,6 +402,34 @@ export const crmQuotationAttachments = pgTable('crm_quotation_attachments', {
deletedAt: timestamp('deleted_at', { withTimezone: true }) deletedAt: timestamp('deleted_at', { withTimezone: true })
}); });
export const crmDocumentArtifacts = pgTable('crm_document_artifacts', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
entityType: text('entity_type').notNull(),
entityId: text('entity_id').notNull(),
documentType: text('document_type').notNull(),
artifactType: text('artifact_type').notNull(),
templateVersionId: text('template_version_id'),
storageProvider: text('storage_provider').notNull(),
storageKey: text('storage_key').notNull(),
fileName: text('file_name').notNull(),
contentType: text('content_type').notNull(),
fileSize: integer('file_size'),
checksum: text('checksum'),
status: text('status').default('active').notNull(),
generatedBy: text('generated_by').notNull(),
generatedAt: timestamp('generated_at', { withTimezone: true }).defaultNow().notNull(),
lockedAt: timestamp('locked_at', { withTimezone: true }),
lockedBy: text('locked_by'),
voidedAt: timestamp('voided_at', { withTimezone: true }),
voidedBy: text('voided_by'),
voidReason: text('void_reason'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
});
export const crmApprovalWorkflows = pgTable( export const crmApprovalWorkflows = pgTable(
'crm_approval_workflows', 'crm_approval_workflows',
{ {

View File

@@ -44,6 +44,21 @@ export interface QuotationSalesmanLookup {
name: string; name: string;
} }
export interface QuotationApprovedArtifactSummary {
id: string;
fileName: string;
contentType: string;
fileSize: number | null;
checksum: string | null;
status: string;
generatedAt: string;
generatedBy: string;
generatedByName: string | null;
lockedAt: string | null;
lockedBy: string | null;
lockedByName: string | null;
}
export interface QuotationRecord { export interface QuotationRecord {
id: string; id: string;
organizationId: string; organizationId: string;
@@ -80,9 +95,12 @@ export interface QuotationRecord {
sentAt: string | null; sentAt: string | null;
sentVia: string | null; sentVia: string | null;
approvedAt: string | null; approvedAt: string | null;
approvedArtifactId: string | null;
approvedPdfUrl: string | null; approvedPdfUrl: string | null;
approvedSnapshot: unknown | null; approvedSnapshot: unknown | null;
approvedTemplateVersionId: string | null; approvedTemplateVersionId: string | null;
approvedArtifact: QuotationApprovedArtifactSummary | null;
hasLegacyApprovedPdf: boolean;
acceptedAt: string | null; acceptedAt: string | null;
rejectedAt: string | null; rejectedAt: string | null;
rejectionReason: string | null; rejectionReason: string | null;

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,6 @@
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
import { import {
and, crmDocumentArtifacts,
asc,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
type SQL
} from 'drizzle-orm';
import {
crmCustomerContacts, crmCustomerContacts,
crmCustomers, crmCustomers,
crmEnquiries, crmEnquiries,
@@ -34,6 +24,7 @@ import type {
QuotationActivityRecord, QuotationActivityRecord,
QuotationAttachmentMutationPayload, QuotationAttachmentMutationPayload,
QuotationAttachmentRecord, QuotationAttachmentRecord,
QuotationApprovedArtifactSummary,
QuotationBranchOption, QuotationBranchOption,
QuotationContactLookup, QuotationContactLookup,
QuotationCustomerListItem, QuotationCustomerListItem,
@@ -95,7 +86,30 @@ function mapBranch(
}; };
} }
function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRecord { function mapApprovedArtifactSummary(
row: typeof crmDocumentArtifacts.$inferSelect,
options?: { generatedByName?: string | null; lockedByName?: string | null }
): QuotationApprovedArtifactSummary {
return {
id: row.id,
fileName: row.fileName,
contentType: row.contentType,
fileSize: row.fileSize,
checksum: row.checksum,
status: row.status,
generatedAt: row.generatedAt.toISOString(),
generatedBy: row.generatedBy,
generatedByName: options?.generatedByName ?? null,
lockedAt: row.lockedAt?.toISOString() ?? null,
lockedBy: row.lockedBy,
lockedByName: options?.lockedByName ?? null
};
}
function mapQuotationRecord(
row: typeof crmQuotations.$inferSelect,
options?: { approvedArtifact?: QuotationApprovedArtifactSummary | null }
): QuotationRecord {
return { return {
id: row.id, id: row.id,
organizationId: row.organizationId, organizationId: row.organizationId,
@@ -132,9 +146,15 @@ function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRe
sentAt: row.sentAt?.toISOString() ?? null, sentAt: row.sentAt?.toISOString() ?? null,
sentVia: row.sentVia, sentVia: row.sentVia,
approvedAt: row.approvedAt?.toISOString() ?? null, approvedAt: row.approvedAt?.toISOString() ?? null,
approvedArtifactId: row.approvedArtifactId,
approvedPdfUrl: row.approvedPdfUrl, approvedPdfUrl: row.approvedPdfUrl,
approvedSnapshot: row.approvedSnapshot ?? null, approvedSnapshot: row.approvedSnapshot ?? null,
approvedTemplateVersionId: row.approvedTemplateVersionId, approvedTemplateVersionId: row.approvedTemplateVersionId,
approvedArtifact: options?.approvedArtifact ?? null,
hasLegacyApprovedPdf:
!row.approvedArtifactId &&
typeof row.approvedPdfUrl === 'string' &&
row.approvedPdfUrl.startsWith('/generated/'),
acceptedAt: row.acceptedAt?.toISOString() ?? null, acceptedAt: row.acceptedAt?.toISOString() ?? null,
rejectedAt: row.rejectedAt?.toISOString() ?? null, rejectedAt: row.rejectedAt?.toISOString() ?? null,
rejectionReason: row.rejectionReason, rejectionReason: row.rejectionReason,
@@ -334,11 +354,7 @@ async function resolveOptionCodeById(
return options.find((option) => option.id === optionId)?.code ?? null; return options.find((option) => option.id === optionId)?.code ?? null;
} }
async function resolveOptionIdByCode( async function resolveOptionIdByCode(organizationId: string, category: string, code: string) {
organizationId: string,
category: string,
code: string
) {
const options = await getActiveOptionsByCategory(category, { organizationId }); const options = await getActiveOptionsByCategory(category, { organizationId });
return options.find((option) => option.code === code)?.id ?? null; return options.find((option) => option.code === code)?.id ?? null;
} }
@@ -724,7 +740,11 @@ function buildQuotationFilters(organizationId: string, filters: QuotationFilters
]; ];
} }
async function refreshQuotationTotals(quotationId: string, organizationId: string, userId?: string) { async function refreshQuotationTotals(
quotationId: string,
organizationId: string,
userId?: string
) {
const quotation = await assertQuotationBelongsToOrganization(quotationId, organizationId); const quotation = await assertQuotationBelongsToOrganization(quotationId, organizationId);
const items = await listQuotationItems(quotationId, organizationId); const items = await listQuotationItems(quotationId, organizationId);
const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0); const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0);
@@ -903,7 +923,9 @@ export async function listQuotations(
contactIds.length contactIds.length
? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds)) ? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds))
: [], : [],
enquiryIds.length ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds)) : [], enquiryIds.length
? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds))
: [],
quotationIds.length quotationIds.length
? db ? db
.select({ quotationId: crmQuotationItems.quotationId, value: count() }) .select({ quotationId: crmQuotationItems.quotationId, value: count() })
@@ -942,7 +964,47 @@ export async function getQuotationDetail(
organizationId: string organizationId: string
): Promise<QuotationRecord> { ): Promise<QuotationRecord> {
const quotation = await assertQuotationBelongsToOrganization(id, organizationId); const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
return mapQuotationRecord(quotation); const approvedArtifactId =
quotation.approvedArtifactId ??
(quotation.approvedPdfUrl?.startsWith('artifact:')
? quotation.approvedPdfUrl.replace('artifact:', '')
: null);
if (!approvedArtifactId) {
return mapQuotationRecord(quotation);
}
const [artifact] = await db
.select()
.from(crmDocumentArtifacts)
.where(
and(
eq(crmDocumentArtifacts.id, approvedArtifactId),
eq(crmDocumentArtifacts.organizationId, organizationId),
isNull(crmDocumentArtifacts.deletedAt)
)
)
.limit(1);
if (!artifact) {
return mapQuotationRecord(quotation);
}
const relatedUserIds = [artifact.generatedBy, artifact.lockedBy].filter(Boolean) as string[];
const actorRows = relatedUserIds.length
? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, relatedUserIds))
: [];
const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));
return mapQuotationRecord(quotation, {
approvedArtifact: mapApprovedArtifactSummary(artifact, {
generatedByName: actorMap.get(artifact.generatedBy) ?? null,
lockedByName: artifact.lockedBy ? (actorMap.get(artifact.lockedBy) ?? null) : null
})
});
} }
export async function getQuotationActivity( export async function getQuotationActivity(
@@ -961,11 +1023,15 @@ export async function getQuotationActivity(
log.entityType === 'crm_quotation_customer' || log.entityType === 'crm_quotation_customer' ||
log.entityType === 'crm_quotation_topic' || log.entityType === 'crm_quotation_topic' ||
log.entityType === 'crm_quotation_followup' || log.entityType === 'crm_quotation_followup' ||
log.entityType === 'crm_quotation_attachment' log.entityType === 'crm_quotation_attachment' ||
log.entityType === 'crm_document_artifact'
); );
const userIds = [...new Set(filtered.map((log) => log.userId))]; const userIds = [...new Set(filtered.map((log) => log.userId))];
const actorRows = userIds.length const actorRows = userIds.length
? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, userIds)) ? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, userIds))
: []; : [];
const actorMap = new Map(actorRows.map((row) => [row.id, row.name])); const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));
@@ -1029,6 +1095,7 @@ export async function createQuotation(
isSent: false, isSent: false,
sentVia: payload.sentVia ?? null, sentVia: payload.sentVia ?? null,
approvedAt: null, approvedAt: null,
approvedArtifactId: null,
approvedPdfUrl: null, approvedPdfUrl: null,
approvedSnapshot: null, approvedSnapshot: null,
approvedTemplateVersionId: null, approvedTemplateVersionId: null,
@@ -1119,23 +1186,48 @@ export async function softDeleteQuotation(id: string, organizationId: string, us
db db
.update(crmQuotationItems) .update(crmQuotationItems)
.set({ deletedAt: now, updatedAt: now }) .set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationItems.quotationId, id), eq(crmQuotationItems.organizationId, organizationId))), .where(
and(
eq(crmQuotationItems.quotationId, id),
eq(crmQuotationItems.organizationId, organizationId)
)
),
db db
.update(crmQuotationCustomers) .update(crmQuotationCustomers)
.set({ deletedAt: now, updatedAt: now }) .set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationCustomers.quotationId, id), eq(crmQuotationCustomers.organizationId, organizationId))), .where(
and(
eq(crmQuotationCustomers.quotationId, id),
eq(crmQuotationCustomers.organizationId, organizationId)
)
),
db db
.update(crmQuotationTopics) .update(crmQuotationTopics)
.set({ deletedAt: now, updatedAt: now }) .set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationTopics.quotationId, id), eq(crmQuotationTopics.organizationId, organizationId))), .where(
and(
eq(crmQuotationTopics.quotationId, id),
eq(crmQuotationTopics.organizationId, organizationId)
)
),
db db
.update(crmQuotationFollowups) .update(crmQuotationFollowups)
.set({ deletedAt: now, updatedAt: now, updatedBy: userId }) .set({ deletedAt: now, updatedAt: now, updatedBy: userId })
.where(and(eq(crmQuotationFollowups.quotationId, id), eq(crmQuotationFollowups.organizationId, organizationId))), .where(
and(
eq(crmQuotationFollowups.quotationId, id),
eq(crmQuotationFollowups.organizationId, organizationId)
)
),
db db
.update(crmQuotationAttachments) .update(crmQuotationAttachments)
.set({ deletedAt: now, updatedAt: now }) .set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationAttachments.quotationId, id), eq(crmQuotationAttachments.organizationId, organizationId))) .where(
and(
eq(crmQuotationAttachments.quotationId, id),
eq(crmQuotationAttachments.organizationId, organizationId)
)
)
]); ]);
return updated; return updated;
@@ -1506,7 +1598,12 @@ export async function softDeleteQuotationTopic(
await db await db
.update(crmQuotationTopicItems) .update(crmQuotationTopicItems)
.set({ deletedAt: now, updatedAt: now }) .set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationTopicItems.topicId, topicId), eq(crmQuotationTopicItems.organizationId, organizationId))); .where(
and(
eq(crmQuotationTopicItems.topicId, topicId),
eq(crmQuotationTopicItems.organizationId, organizationId)
)
);
return updated; return updated;
} }
@@ -1708,11 +1805,8 @@ export async function createQuotationRevision(
); );
} }
const revisedStatusId = const revisedStatusId =
(await resolveOptionIdByCode( (await resolveOptionIdByCode(organizationId, QUOTATION_OPTION_CATEGORIES.status, 'revised')) ??
organizationId, parent.status;
QUOTATION_OPTION_CATEGORIES.status,
'revised'
)) ?? parent.status;
const familyRootId = parent.parentQuotationId ?? parent.id; const familyRootId = parent.parentQuotationId ?? parent.id;
const familyRows = await db const familyRows = await db
@@ -1774,6 +1868,7 @@ export async function createQuotationRevision(
isSent: false, isSent: false,
sentVia: parent.sentVia, sentVia: parent.sentVia,
approvedAt: null, approvedAt: null,
approvedArtifactId: null,
approvedPdfUrl: null, approvedPdfUrl: null,
approvedSnapshot: null, approvedSnapshot: null,
approvedTemplateVersionId: null, approvedTemplateVersionId: null,

View File

@@ -1,15 +1,4 @@
import { import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
and,
asc,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
type SQL
} from 'drizzle-orm';
import { import {
crmApprovalActions, crmApprovalActions,
crmApprovalRequests, crmApprovalRequests,
@@ -159,11 +148,7 @@ async function resolveQuotationStatusCodeById(organizationId: string, id: string
return options.find((option) => option.id === id)?.code ?? null; return options.find((option) => option.id === id)?.code ?? null;
} }
async function assertWorkflowByCode( async function assertWorkflowByCode(organizationId: string, code: string, entityType?: string) {
organizationId: string,
code: string,
entityType?: string
) {
const [workflow] = await db const [workflow] = await db
.select() .select()
.from(crmApprovalWorkflows) .from(crmApprovalWorkflows)
@@ -322,15 +307,13 @@ async function syncQuotationStatusFromApproval(
}; };
if (nextStatusCode !== 'approved') { if (nextStatusCode !== 'approved') {
nextValues.approvedArtifactId = null;
nextValues.approvedPdfUrl = null; nextValues.approvedPdfUrl = null;
nextValues.approvedSnapshot = null; nextValues.approvedSnapshot = null;
nextValues.approvedTemplateVersionId = null; nextValues.approvedTemplateVersionId = null;
} }
await db await db.update(crmQuotations).set(nextValues).where(eq(crmQuotations.id, quotationId));
.update(crmQuotations)
.set(nextValues)
.where(eq(crmQuotations.id, quotationId));
} }
async function syncEntityStatusFromApproval( async function syncEntityStatusFromApproval(
@@ -365,11 +348,7 @@ async function syncEntityStatusFromApproval(
} }
} }
async function assertActorCanHandleStep( async function assertActorCanHandleStep(organizationId: string, userId: string, roleCode: string) {
organizationId: string,
userId: string,
roleCode: string
) {
const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1); const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!userRow) { if (!userRow) {
@@ -473,12 +452,14 @@ export async function listApprovalRequests(
const requesterIds = [...new Set(rows.map((row) => row.requestedBy))]; const requesterIds = [...new Set(rows.map((row) => row.requestedBy))];
const [workflows, requesters, entitySummaries] = await Promise.all([ const [workflows, requesters, entitySummaries] = await Promise.all([
workflowIds.length workflowIds.length
? db ? db.select().from(crmApprovalWorkflows).where(inArray(crmApprovalWorkflows.id, workflowIds))
.select() : [],
.from(crmApprovalWorkflows) requesterIds.length
.where(inArray(crmApprovalWorkflows.id, workflowIds)) ? db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, requesterIds))
: [], : [],
requesterIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, requesterIds)) : [],
resolveEntitySummaries( resolveEntitySummaries(
organizationId, organizationId,
rows.map((row) => ({ entityType: row.entityType, entityId: row.entityId })) rows.map((row) => ({ entityType: row.entityType, entityId: row.entityId }))
@@ -544,7 +525,10 @@ export async function getApprovalTimeline(
.orderBy(asc(crmApprovalActions.actedAt), asc(crmApprovalActions.createdAt)); .orderBy(asc(crmApprovalActions.actedAt), asc(crmApprovalActions.createdAt));
const actorIds = [...new Set(rows.map((row) => row.actedBy))]; const actorIds = [...new Set(rows.map((row) => row.actedBy))];
const actors = actorIds.length const actors = actorIds.length
? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, actorIds)) ? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, actorIds))
: []; : [];
const actorMap = new Map(actors.map((row) => [row.id, row.name])); const actorMap = new Map(actors.map((row) => [row.id, row.name]));

View File

@@ -0,0 +1,313 @@
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
import { crmDocumentArtifacts, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { auditAction } from '@/features/foundation/audit-log/service';
import { getStorageProvider } from '@/features/foundation/storage/service';
export type DocumentArtifactRecord = {
id: string;
organizationId: string;
entityType: string;
entityId: string;
documentType: string;
artifactType: string;
templateVersionId: string | null;
storageProvider: string;
storageKey: string;
fileName: string;
contentType: string;
fileSize: number | null;
checksum: string | null;
status: string;
generatedBy: string;
generatedByName: string | null;
generatedAt: string;
lockedAt: string | null;
lockedBy: string | null;
lockedByName: string | null;
voidedAt: string | null;
voidedBy: string | null;
voidReason: string | null;
metadata: unknown | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
function mapDocumentArtifactRecord(
row: typeof crmDocumentArtifacts.$inferSelect,
names?: { generatedByName?: string | null; lockedByName?: string | null }
): DocumentArtifactRecord {
return {
id: row.id,
organizationId: row.organizationId,
entityType: row.entityType,
entityId: row.entityId,
documentType: row.documentType,
artifactType: row.artifactType,
templateVersionId: row.templateVersionId,
storageProvider: row.storageProvider,
storageKey: row.storageKey,
fileName: row.fileName,
contentType: row.contentType,
fileSize: row.fileSize,
checksum: row.checksum,
status: row.status,
generatedBy: row.generatedBy,
generatedByName: names?.generatedByName ?? null,
generatedAt: row.generatedAt.toISOString(),
lockedAt: row.lockedAt?.toISOString() ?? null,
lockedBy: row.lockedBy,
lockedByName: names?.lockedByName ?? null,
voidedAt: row.voidedAt?.toISOString() ?? null,
voidedBy: row.voidedBy,
voidReason: row.voidReason,
metadata: row.metadata ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
async function enrichArtifact(row: typeof crmDocumentArtifacts.$inferSelect) {
const userIds = [row.generatedBy, row.lockedBy].filter(Boolean) as string[];
const userRows = userIds.length
? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, userIds))
: [];
const userMap = new Map(userRows.map((user) => [user.id, user.name]));
return mapDocumentArtifactRecord(row, {
generatedByName: userMap.get(row.generatedBy) ?? null,
lockedByName: row.lockedBy ? (userMap.get(row.lockedBy) ?? null) : null
});
}
export async function createArtifact(input: {
organizationId: string;
userId: string;
entityType: string;
entityId: string;
documentType: string;
artifactType: 'preview' | 'approved_pdf' | 'export';
templateVersionId?: string | null;
storageProvider: string;
storageKey: string;
fileName: string;
contentType: string;
fileSize?: number | null;
checksum?: string | null;
metadata?: unknown;
}) {
const [created] = await db
.insert(crmDocumentArtifacts)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
entityType: input.entityType,
entityId: input.entityId,
documentType: input.documentType,
artifactType: input.artifactType,
templateVersionId: input.templateVersionId ?? null,
storageProvider: input.storageProvider,
storageKey: input.storageKey,
fileName: input.fileName,
contentType: input.contentType,
fileSize: input.fileSize ?? null,
checksum: input.checksum ?? null,
status: 'active',
generatedBy: input.userId,
generatedAt: new Date(),
metadata: input.metadata ?? null
})
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_artifact',
entityId: created.id,
action: 'create',
afterData: created
});
return enrichArtifact(created);
}
async function getArtifactRow(id: string, organizationId: string) {
const [artifact] = await db
.select()
.from(crmDocumentArtifacts)
.where(
and(
eq(crmDocumentArtifacts.id, id),
eq(crmDocumentArtifacts.organizationId, organizationId),
isNull(crmDocumentArtifacts.deletedAt)
)
)
.limit(1);
if (!artifact) {
throw new AuthError('Document artifact not found', 404);
}
return artifact;
}
export async function getArtifact(id: string, organizationId: string) {
return enrichArtifact(await getArtifactRow(id, organizationId));
}
export async function getActiveArtifactForEntity(input: {
organizationId: string;
entityType: string;
entityId: string;
artifactType: string;
}) {
const artifacts = await db
.select()
.from(crmDocumentArtifacts)
.where(
and(
eq(crmDocumentArtifacts.organizationId, input.organizationId),
eq(crmDocumentArtifacts.entityType, input.entityType),
eq(crmDocumentArtifacts.entityId, input.entityId),
eq(crmDocumentArtifacts.artifactType, input.artifactType),
isNull(crmDocumentArtifacts.deletedAt)
)
)
.orderBy(desc(crmDocumentArtifacts.createdAt));
const artifact = artifacts.find((row) => row.status === 'active' || row.status === 'locked');
if (!artifact || artifact.status === 'voided' || artifact.status === 'deleted') {
return null;
}
return enrichArtifact(artifact);
}
export async function lockArtifact(input: {
artifactId: string;
organizationId: string;
userId: string;
}) {
const row = await getArtifactRow(input.artifactId, input.organizationId);
const [updated] = await db
.update(crmDocumentArtifacts)
.set({
status: 'locked',
lockedAt: row.lockedAt ?? new Date(),
lockedBy: row.lockedBy ?? input.userId,
updatedAt: new Date()
})
.where(eq(crmDocumentArtifacts.id, input.artifactId))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_artifact',
entityId: updated.id,
action: 'lock',
beforeData: row,
afterData: updated
});
return enrichArtifact(updated);
}
export async function voidArtifact(input: {
artifactId: string;
organizationId: string;
userId: string;
reason: string;
}) {
const row = await getArtifactRow(input.artifactId, input.organizationId);
const [updated] = await db
.update(crmDocumentArtifacts)
.set({
status: 'voided',
voidedAt: new Date(),
voidedBy: input.userId,
voidReason: input.reason.trim(),
updatedAt: new Date()
})
.where(eq(crmDocumentArtifacts.id, input.artifactId))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_artifact',
entityId: updated.id,
action: 'void',
beforeData: row,
afterData: updated
});
return enrichArtifact(updated);
}
export async function deleteArtifactMetadataOnly(input: {
artifactId: string;
organizationId: string;
}) {
await db
.update(crmDocumentArtifacts)
.set({
status: 'deleted',
deletedAt: new Date(),
updatedAt: new Date()
})
.where(
and(
eq(crmDocumentArtifacts.id, input.artifactId),
eq(crmDocumentArtifacts.organizationId, input.organizationId)
)
);
}
export async function downloadArtifact(input: {
artifactId: string;
organizationId: string;
userId?: string;
auditDownload?: boolean;
}) {
const artifact = await getArtifactRow(input.artifactId, input.organizationId);
if (artifact.status === 'voided' || artifact.status === 'deleted') {
throw new AuthError('Document artifact is not available for download', 409);
}
const object = await getStorageProvider().getObject({
key: artifact.storageKey
});
if (input.auditDownload && input.userId) {
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_artifact',
entityId: artifact.id,
action: 'download',
afterData: {
storageKey: artifact.storageKey,
fileName: artifact.fileName
}
});
}
return {
artifact: await enrichArtifact(artifact),
object: {
...object,
contentType: artifact.contentType,
fileName: artifact.fileName,
size: artifact.fileSize ?? object.size,
checksum: artifact.checksum
}
};
}

View File

@@ -1,16 +1,23 @@
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import type { Font, Template } from '@pdfme/common'; import type { Font, Template } from '@pdfme/common';
import { generate } from '@pdfme/generator'; import { generate } from '@pdfme/generator';
import { image, line, table, text } from '@pdfme/schemas'; import { image, line, table, text } from '@pdfme/schemas';
import { and, eq, isNull } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { import { crmQuotationAttachments, crmQuotations, organizations } from '@/db/schema';
crmQuotationAttachments,
crmQuotations,
organizations
} from '@/db/schema';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session'; import { AuthError } from '@/lib/auth/session';
import {
createArtifact,
downloadArtifact,
getActiveArtifactForEntity,
lockArtifact
} from '@/features/foundation/document-artifact/server/service';
import {
buildOrganizationStorageKey,
getStorageProvider
} from '@/features/foundation/storage/service';
import { sha256 } from '@/features/foundation/storage/server/checksum';
import { import {
getQuotationDocumentPreviewData, getQuotationDocumentPreviewData,
prepareApprovedQuotationSnapshot prepareApprovedQuotationSnapshot
@@ -27,6 +34,8 @@ type GeneratedQuotationPdf = {
fileName: string; fileName: string;
templateVersionId: string; templateVersionId: string;
approvedPdfUrl?: string | null; approvedPdfUrl?: string | null;
approvedArtifactId?: string | null;
isLegacy?: boolean;
}; };
const TEMPLATE_FONT_FALLBACKS = new Set(['cordia', 'cordiaBold']); const TEMPLATE_FONT_FALLBACKS = new Set(['cordia', 'cordiaBold']);
@@ -72,11 +81,7 @@ function normalizeTemplateFonts<T>(value: T): T {
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
const nextObject = Object.fromEntries( const nextObject = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, child]) => { Object.entries(value as Record<string, unknown>).map(([key, child]) => {
if ( if (key === 'fontName' && typeof child === 'string' && TEMPLATE_FONT_FALLBACKS.has(child)) {
key === 'fontName' &&
typeof child === 'string' &&
TEMPLATE_FONT_FALLBACKS.has(child)
) {
return [key, 'Roboto']; return [key, 'Roboto'];
} }
@@ -128,18 +133,6 @@ function toSafeFileName(value: string) {
return value.replace(/[^a-zA-Z0-9._-]/g, '-'); return value.replace(/[^a-zA-Z0-9._-]/g, '-');
} }
async function ensureGeneratedDirectory(organizationId: string) {
const directory = path.join(
process.cwd(),
'public',
'generated',
'quotations',
organizationId
);
await mkdir(directory, { recursive: true });
return directory;
}
async function persistApprovedQuotationArtifact(args: { async function persistApprovedQuotationArtifact(args: {
quotationId: string; quotationId: string;
organizationId: string; organizationId: string;
@@ -149,31 +142,87 @@ async function persistApprovedQuotationArtifact(args: {
templateVersionId: string; templateVersionId: string;
}) { }) {
const quotation = await getQuotationDetail(args.quotationId, args.organizationId); const quotation = await getQuotationDetail(args.quotationId, args.organizationId);
if (quotation.approvedArtifactId && quotation.approvedArtifact?.status === 'locked') {
throw new AuthError(
'This quotation already has a locked approved PDF artifact and cannot be overwritten',
409
);
}
const existingArtifact = await getActiveArtifactForEntity({
organizationId: args.organizationId,
entityType: 'crm_quotation',
entityId: args.quotationId,
artifactType: 'approved_pdf'
});
if (existingArtifact?.status === 'locked') {
throw new AuthError(
'This quotation already has a locked approved PDF artifact and cannot be overwritten',
409
);
}
if (existingArtifact) {
throw new AuthError(
'An approved PDF artifact already exists for this quotation. Void it first before regenerating.',
409
);
}
const snapshot = await prepareApprovedQuotationSnapshot( const snapshot = await prepareApprovedQuotationSnapshot(
args.quotationId, args.quotationId,
args.organizationId, args.organizationId,
args.userId args.userId
); );
const directory = await ensureGeneratedDirectory(args.organizationId); const storageProvider = getStorageProvider();
const safeFileName = toSafeFileName(args.fileName); const safeFileName = toSafeFileName(args.fileName);
const absolutePath = path.join(directory, safeFileName); const checksum = sha256(args.buffer);
const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`; const storageKey = buildOrganizationStorageKey(args.organizationId, [
'quotations',
'approved',
`${args.quotationId}-${safeFileName}`
]);
try { const stored = await storageProvider.putObject({
await writeFile(absolutePath, args.buffer); key: storageKey,
} catch { body: args.buffer,
throw new AuthError('Unable to persist approved PDF to local storage', 500); contentType: 'application/pdf',
} fileName: safeFileName
const fileStats = await stat(absolutePath).catch(() => {
throw new AuthError('Approved PDF was written but could not be verified on disk', 500);
}); });
const artifact = await createArtifact({
organizationId: args.organizationId,
userId: args.userId,
entityType: 'crm_quotation',
entityId: args.quotationId,
documentType: 'quotation_pdf',
artifactType: 'approved_pdf',
templateVersionId: args.templateVersionId,
storageProvider: stored.provider,
storageKey: stored.key,
fileName: safeFileName,
contentType: 'application/pdf',
fileSize: stored.size,
checksum,
metadata: {
quotationCode: quotation.code,
source: 'task-i-storage-provider'
}
});
const lockedArtifact = await lockArtifact({
artifactId: artifact.id,
organizationId: args.organizationId,
userId: args.userId
});
const artifactReference = `artifact:${lockedArtifact.id}`;
await db await db
.update(crmQuotations) .update(crmQuotations)
.set({ .set({
approvedAt: quotation.approvedAt ? new Date(quotation.approvedAt) : new Date(), approvedAt: quotation.approvedAt ? new Date(quotation.approvedAt) : new Date(),
approvedPdfUrl: publicPath, approvedArtifactId: lockedArtifact.id,
approvedPdfUrl: artifactReference,
approvedSnapshot: snapshot, approvedSnapshot: snapshot,
approvedTemplateVersionId: args.templateVersionId, approvedTemplateVersionId: args.templateVersionId,
updatedAt: new Date(), updatedAt: new Date(),
@@ -181,18 +230,9 @@ async function persistApprovedQuotationArtifact(args: {
}) })
.where(eq(crmQuotations.id, args.quotationId)); .where(eq(crmQuotations.id, args.quotationId));
const existingAttachment = await db const existingAttachment = await db.query.crmQuotationAttachments.findFirst({
.select() where: eq(crmQuotationAttachments.quotationId, args.quotationId)
.from(crmQuotationAttachments) });
.where(
and(
eq(crmQuotationAttachments.quotationId, args.quotationId),
eq(crmQuotationAttachments.organizationId, args.organizationId),
eq(crmQuotationAttachments.filePath, publicPath),
isNull(crmQuotationAttachments.deletedAt)
)
)
.then((rows) => rows[0] ?? null);
if (existingAttachment) { if (existingAttachment) {
await db await db
@@ -200,10 +240,10 @@ async function persistApprovedQuotationArtifact(args: {
.set({ .set({
fileName: safeFileName, fileName: safeFileName,
originalFileName: safeFileName, originalFileName: safeFileName,
filePath: publicPath, filePath: artifactReference,
fileSize: fileStats.size, fileSize: stored.size,
fileType: 'application/pdf', fileType: 'application/pdf',
description: 'Approved quotation PDF', description: `Approved quotation PDF artifact ${lockedArtifact.id}`,
uploadedAt: new Date(), uploadedAt: new Date(),
uploadedBy: args.userId, uploadedBy: args.userId,
updatedAt: new Date() updatedAt: new Date()
@@ -216,21 +256,46 @@ async function persistApprovedQuotationArtifact(args: {
quotationId: args.quotationId, quotationId: args.quotationId,
fileName: safeFileName, fileName: safeFileName,
originalFileName: safeFileName, originalFileName: safeFileName,
filePath: publicPath, filePath: artifactReference,
fileSize: fileStats.size, fileSize: stored.size,
fileType: 'application/pdf', fileType: 'application/pdf',
description: 'Approved quotation PDF', description: `Approved quotation PDF artifact ${lockedArtifact.id}`,
uploadedBy: args.userId uploadedBy: args.userId
}); });
} }
return { return {
publicPath, artifactId: lockedArtifact.id,
fileSize: fileStats.size, artifactReference,
snapshot snapshot
}; };
} }
async function loadLegacyApprovedQuotationPdf(
quotationId: string,
organizationId: string
): Promise<GeneratedQuotationPdf> {
const quotation = await getQuotationDetail(quotationId, organizationId);
if (!quotation.approvedPdfUrl || !quotation.approvedPdfUrl.startsWith('/generated/')) {
throw new AuthError('Approved PDF has not been generated yet', 404);
}
const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
const absolutePath = path.join(process.cwd(), 'public', relativePath);
const buffer = await readFile(absolutePath).catch(() => {
throw new AuthError('Approved PDF file is missing from legacy local storage', 404);
});
return {
buffer,
fileName: `${quotation.code}-approved.pdf`,
templateVersionId: quotation.approvedTemplateVersionId ?? '',
approvedPdfUrl: quotation.approvedPdfUrl,
isLegacy: true
};
}
export async function generateQuotationPdf( export async function generateQuotationPdf(
quotationId: string, quotationId: string,
organizationId: string organizationId: string
@@ -248,10 +313,7 @@ export async function generateQuotationPdf(
}; };
} }
export async function generateQuotationPreviewPdf( export async function generateQuotationPreviewPdf(quotationId: string, organizationId: string) {
quotationId: string,
organizationId: string
) {
return generateQuotationPdf(quotationId, organizationId); return generateQuotationPdf(quotationId, organizationId);
} }
@@ -284,31 +346,46 @@ export async function generateApprovedQuotationPdf(
buffer, buffer,
fileName, fileName,
templateVersionId: preview.template.version.id, templateVersionId: preview.template.version.id,
approvedPdfUrl: artifact.publicPath approvedPdfUrl: artifact.artifactReference,
approvedArtifactId: artifact.artifactId
}; };
} }
export async function getStoredApprovedQuotationPdf( export async function getStoredApprovedQuotationPdf(
quotationId: string, quotationId: string,
organizationId: string organizationId: string,
options?: {
userId?: string;
auditDownload?: boolean;
}
): Promise<GeneratedQuotationPdf> { ): Promise<GeneratedQuotationPdf> {
const quotation = await getQuotationDetail(quotationId, organizationId); const quotation = await getQuotationDetail(quotationId, organizationId);
const approvedArtifactId =
quotation.approvedArtifactId ??
(quotation.approvedPdfUrl?.startsWith('artifact:')
? quotation.approvedPdfUrl.replace('artifact:', '')
: null);
if (!quotation.approvedPdfUrl) { if (approvedArtifactId) {
throw new AuthError('Approved PDF has not been generated yet', 404); const { object } = await downloadArtifact({
artifactId: approvedArtifactId,
organizationId,
userId: options?.userId,
auditDownload: options?.auditDownload
});
const response = new Response(object.body);
const buffer = Buffer.from(await response.arrayBuffer());
return {
buffer,
fileName: object.fileName || `${quotation.code}-approved.pdf`,
templateVersionId: quotation.approvedTemplateVersionId ?? '',
approvedPdfUrl: quotation.approvedPdfUrl,
approvedArtifactId
};
} }
const relativePath = quotation.approvedPdfUrl.replace(/^\//, ''); return loadLegacyApprovedQuotationPdf(quotationId, organizationId);
const absolutePath = path.join(process.cwd(), 'public', relativePath);
const buffer = await readFile(absolutePath).catch(() => {
throw new AuthError('Approved PDF file is missing from local storage', 404);
});
return {
buffer,
fileName: `${quotation.code}-approved.pdf`,
templateVersionId: quotation.approvedTemplateVersionId ?? ''
};
} }
export async function getOrganizationGeneratedPdfDirectory(organizationId: string) { export async function getOrganizationGeneratedPdfDirectory(organizationId: string) {
@@ -322,5 +399,5 @@ export async function getOrganizationGeneratedPdfDirectory(organizationId: strin
throw new AuthError('Organization not found', 404); throw new AuthError('Organization not found', 404);
} }
return ensureGeneratedDirectory(organizationId); return buildOrganizationStorageKey(organizationId, ['quotations']);
} }

View File

@@ -0,0 +1,5 @@
import { createHash } from 'node:crypto';
export function sha256(buffer: Buffer) {
return createHash('sha256').update(buffer).digest('hex');
}

View File

@@ -0,0 +1,77 @@
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import type {
DeleteObjectInput,
GetObjectInput,
ObjectExistsInput,
PutObjectInput,
StorageProvider
} from '../types';
function resolveLocalRoot() {
return path.resolve(process.cwd(), process.env.LOCAL_STORAGE_ROOT?.trim() || 'storage');
}
function resolveAbsolutePath(key: string) {
return path.resolve(resolveLocalRoot(), key);
}
function assertRelativeKey(key: string) {
const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized || normalized.includes('..')) {
throw new Error('Storage key must be a safe relative path');
}
return normalized;
}
export function createLocalStorageProvider(): StorageProvider {
return {
async putObject(input: PutObjectInput) {
const key = assertRelativeKey(input.key);
const absolutePath = resolveAbsolutePath(key);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, input.body);
return {
provider: 'local',
key,
contentType: input.contentType,
fileName: input.fileName,
size: input.body.byteLength
};
},
async getObject(input: GetObjectInput) {
const key = assertRelativeKey(input.key);
const absolutePath = resolveAbsolutePath(key);
const [buffer, fileStats] = await Promise.all([readFile(absolutePath), stat(absolutePath)]);
return {
provider: 'local',
key,
contentType: 'application/octet-stream',
size: fileStats.size,
body: Readable.toWeb(Readable.from(buffer)) as ReadableStream<Uint8Array>
};
},
async deleteObject(input: DeleteObjectInput) {
const key = assertRelativeKey(input.key);
const absolutePath = resolveAbsolutePath(key);
await rm(absolutePath, { force: true });
},
async objectExists(input: ObjectExistsInput) {
const key = assertRelativeKey(input.key);
const absolutePath = resolveAbsolutePath(key);
try {
await stat(absolutePath);
return true;
} catch {
return false;
}
}
};
}

View File

@@ -0,0 +1,24 @@
import type { StorageProvider } from '../types';
import { createLocalStorageProvider } from './local-provider';
import { createS3StorageProvider } from './s3-provider';
let cachedProvider: StorageProvider | null = null;
export function resolveStorageProvider(): StorageProvider {
if (cachedProvider) {
return cachedProvider;
}
const provider = process.env.STORAGE_PROVIDER?.trim() || 'local';
switch (provider) {
case 'local':
cachedProvider = createLocalStorageProvider();
return cachedProvider;
case 's3':
cachedProvider = createS3StorageProvider();
return cachedProvider;
default:
throw new Error(`Unsupported storage provider: ${provider}`);
}
}

View File

@@ -0,0 +1,126 @@
import { Readable } from 'node:stream';
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client
} from '@aws-sdk/client-s3';
import { getSignedUrl as signS3Url } from '@aws-sdk/s3-request-presigner';
import type {
DeleteObjectInput,
GetObjectInput,
GetSignedUrlInput,
ObjectExistsInput,
PutObjectInput,
StorageProvider
} from '../types';
function requireS3Env(name: string) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required S3 storage config: ${name}`);
}
return value;
}
function getS3Client() {
return new S3Client({
region: requireS3Env('S3_REGION'),
endpoint: requireS3Env('S3_ENDPOINT'),
credentials: {
accessKeyId: requireS3Env('S3_ACCESS_KEY_ID'),
secretAccessKey: requireS3Env('S3_SECRET_ACCESS_KEY')
},
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false'
});
}
function getBucket() {
return requireS3Env('S3_BUCKET');
}
export function createS3StorageProvider(): StorageProvider {
const client = getS3Client();
const bucket = getBucket();
return {
async putObject(input: PutObjectInput) {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: input.key,
Body: input.body,
ContentType: input.contentType
})
);
return {
provider: 's3',
key: input.key,
contentType: input.contentType,
fileName: input.fileName,
size: input.body.byteLength
};
},
async getObject(input: GetObjectInput) {
const response = await client.send(
new GetObjectCommand({
Bucket: bucket,
Key: input.key
})
);
if (!response.Body) {
throw new Error('S3 object body is empty');
}
const nodeStream = response.Body.transformToWebStream
? response.Body.transformToWebStream()
: (Readable.toWeb(response.Body as Readable) as ReadableStream<Uint8Array>);
return {
provider: 's3',
key: input.key,
contentType: response.ContentType || 'application/octet-stream',
size: Number(response.ContentLength || 0),
body: nodeStream
};
},
async deleteObject(input: DeleteObjectInput) {
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: input.key
})
);
},
async objectExists(input: ObjectExistsInput) {
try {
await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: input.key
})
);
return true;
} catch {
return false;
}
},
async getSignedUrl(input: GetSignedUrlInput) {
return signS3Url(
client,
new GetObjectCommand({
Bucket: bucket,
Key: input.key
}),
{
expiresIn: input.expiresInSeconds ?? 300
}
);
}
};
}

View File

@@ -0,0 +1,20 @@
import 'server-only';
import type { StorageProvider } from './types';
import { resolveStorageProvider } from './server/provider';
export function getStorageProvider(): StorageProvider {
return resolveStorageProvider();
}
export function buildOrganizationStorageKey(organizationId: string, segments: string[]) {
const safeSegments = segments
.map((segment) =>
segment
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '')
)
.filter(Boolean);
return ['organizations', organizationId, ...safeSegments].join('/');
}

View File

@@ -0,0 +1,50 @@
export interface PutObjectInput {
key: string;
body: Buffer;
contentType: string;
fileName?: string;
}
export interface GetObjectInput {
key: string;
}
export interface DeleteObjectInput {
key: string;
}
export interface ObjectExistsInput {
key: string;
}
export interface GetSignedUrlInput {
key: string;
expiresInSeconds?: number;
}
export interface StoredObject {
provider: string;
key: string;
contentType: string;
fileName?: string;
size: number;
checksum?: string | null;
}
export interface StoredObjectStream {
provider: string;
key: string;
contentType: string;
fileName?: string;
size: number;
checksum?: string | null;
body: ReadableStream<Uint8Array>;
}
export interface StorageProvider {
putObject(input: PutObjectInput): Promise<StoredObject>;
getObject(input: GetObjectInput): Promise<StoredObjectStream>;
deleteObject(input: DeleteObjectInput): Promise<void>;
objectExists(input: ObjectExistsInput): Promise<boolean>;
getSignedUrl?(input: GetSignedUrlInput): Promise<string>;
}

View File

@@ -55,6 +55,9 @@ export const PERMISSIONS = {
crmDocumentTemplateCreate: 'crm.document_template.create', crmDocumentTemplateCreate: 'crm.document_template.create',
crmDocumentTemplateUpdate: 'crm.document_template.update', crmDocumentTemplateUpdate: 'crm.document_template.update',
crmDocumentTemplateDelete: 'crm.document_template.delete', crmDocumentTemplateDelete: 'crm.document_template.delete',
crmDocumentArtifactRead: 'crm.document_artifact.read',
crmDocumentArtifactDownload: 'crm.document_artifact.download',
crmDocumentArtifactVoid: 'crm.document_artifact.void',
crmQuotationDocumentPreview: 'crm.quotation.document.preview', crmQuotationDocumentPreview: 'crm.quotation.document.preview',
crmQuotationPdfPreview: 'crm.quotation.pdf.preview', crmQuotationPdfPreview: 'crm.quotation.pdf.preview',
crmQuotationPdfDownload: 'crm.quotation.pdf.download', crmQuotationPdfDownload: 'crm.quotation.pdf.download',
@@ -107,6 +110,9 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmDocumentTemplateCreate, PERMISSIONS.crmDocumentTemplateCreate,
PERMISSIONS.crmDocumentTemplateUpdate, PERMISSIONS.crmDocumentTemplateUpdate,
PERMISSIONS.crmDocumentTemplateDelete, PERMISSIONS.crmDocumentTemplateDelete,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid,
PERMISSIONS.crmQuotationDocumentPreview, PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview, PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload, PERMISSIONS.crmQuotationPdfDownload,
@@ -123,6 +129,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmQuotationRead, PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview, PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview, PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload PERMISSIONS.crmQuotationPdfDownload
@@ -159,6 +167,9 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit, PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid,
PERMISSIONS.crmQuotationDocumentPreview, PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview, PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload PERMISSIONS.crmQuotationPdfDownload
@@ -185,6 +196,8 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit, PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview, PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview, PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload PERMISSIONS.crmQuotationPdfDownload
@@ -210,6 +223,8 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
PERMISSIONS.crmQuotationRevisionCreate, PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview, PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview, PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload PERMISSIONS.crmQuotationPdfDownload