task-i
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
122
docs/implementation/task-h1-pdf-generation-stabilization.md
Normal file
122
docs/implementation/task-h1-pdf-generation-stabilization.md
Normal 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`
|
||||||
155
docs/implementation/task-i-storage-artifact-lifecycle.md
Normal file
155
docs/implementation/task-i-storage-artifact-lifecycle.md
Normal 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`
|
||||||
@@ -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
|
||||||
|
|||||||
30
drizzle/0009_lazy_shard.sql
Normal file
30
drizzle/0009_lazy_shard.sql
Normal 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;
|
||||||
3192
drizzle/meta/0009_snapshot.json
Normal file
3192
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
681
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
475
plans/task-i.md
Normal 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
|
||||||
@@ -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']
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
36
src/app/api/crm/document-artifacts/[id]/download/route.ts
Normal file
36
src/app/api/crm/document-artifacts/[id]/download/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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,9 +964,49 @@ export async function getQuotationDetail(
|
|||||||
organizationId: string
|
organizationId: string
|
||||||
): Promise<QuotationRecord> {
|
): Promise<QuotationRecord> {
|
||||||
const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
|
const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
|
||||||
|
const approvedArtifactId =
|
||||||
|
quotation.approvedArtifactId ??
|
||||||
|
(quotation.approvedPdfUrl?.startsWith('artifact:')
|
||||||
|
? quotation.approvedPdfUrl.replace('artifact:', '')
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if (!approvedArtifactId) {
|
||||||
return mapQuotationRecord(quotation);
|
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(
|
||||||
id: string,
|
id: string,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|
||||||
|
|||||||
313
src/features/foundation/document-artifact/server/service.ts
Normal file
313
src/features/foundation/document-artifact/server/service.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,33 +346,48 @@ 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,
|
||||||
const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
|
userId: options?.userId,
|
||||||
const absolutePath = path.join(process.cwd(), 'public', relativePath);
|
auditDownload: options?.auditDownload
|
||||||
const buffer = await readFile(absolutePath).catch(() => {
|
|
||||||
throw new AuthError('Approved PDF file is missing from local storage', 404);
|
|
||||||
});
|
});
|
||||||
|
const response = new Response(object.body);
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buffer,
|
buffer,
|
||||||
fileName: `${quotation.code}-approved.pdf`,
|
fileName: object.fileName || `${quotation.code}-approved.pdf`,
|
||||||
templateVersionId: quotation.approvedTemplateVersionId ?? ''
|
templateVersionId: quotation.approvedTemplateVersionId ?? '',
|
||||||
|
approvedPdfUrl: quotation.approvedPdfUrl,
|
||||||
|
approvedArtifactId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return loadLegacyApprovedQuotationPdf(quotationId, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getOrganizationGeneratedPdfDirectory(organizationId: string) {
|
export async function getOrganizationGeneratedPdfDirectory(organizationId: string) {
|
||||||
const [organization] = await db
|
const [organization] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/features/foundation/storage/server/checksum.ts
Normal file
5
src/features/foundation/storage/server/checksum.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
export function sha256(buffer: Buffer) {
|
||||||
|
return createHash('sha256').update(buffer).digest('hex');
|
||||||
|
}
|
||||||
77
src/features/foundation/storage/server/local-provider.ts
Normal file
77
src/features/foundation/storage/server/local-provider.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
24
src/features/foundation/storage/server/provider.ts
Normal file
24
src/features/foundation/storage/server/provider.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/features/foundation/storage/server/s3-provider.ts
Normal file
126
src/features/foundation/storage/server/s3-provider.ts
Normal 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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/features/foundation/storage/service.ts
Normal file
20
src/features/foundation/storage/service.ts
Normal 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('/');
|
||||||
|
}
|
||||||
50
src/features/foundation/storage/types.ts
Normal file
50
src/features/foundation/storage/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user