task-d.5.2.1
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
# Task D.5.2: Lead Domain Service & API Foundation
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create the first production Lead service and API slice on top of the D.5.1 lead foundation without changing legacy enquiry behavior.
|
||||||
|
|
||||||
|
## Review Completed
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `docs/standards/project-foundations.md`
|
||||||
|
- `docs/standards/task-catalog.md`
|
||||||
|
- `docs/adr/0018-lead-enquiry-domain-separation.md`
|
||||||
|
- `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
|
||||||
|
- `docs/implementation/task-d51-lead-foundation-stabilization.md`
|
||||||
|
- `src/db/schema.ts`
|
||||||
|
- `src/features/crm/enquiries/server/service.ts`
|
||||||
|
- `src/features/crm/customers/server/service.ts`
|
||||||
|
- `src/features/crm/security/server/service.ts`
|
||||||
|
- `src/features/foundation/audit-log/service.ts`
|
||||||
|
- `src/features/foundation/document-sequence/service.ts`
|
||||||
|
- `src/features/foundation/master-options/service.ts`
|
||||||
|
|
||||||
|
## What Was Added
|
||||||
|
|
||||||
|
- Lead type contracts in [types.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/types.ts)
|
||||||
|
- Production lead service in [service.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/server/service.ts)
|
||||||
|
- Lead request schemas in [lead.schema.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/schemas/lead.schema.ts)
|
||||||
|
- Route handlers:
|
||||||
|
- [route.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/leads/route.ts)
|
||||||
|
- [route.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/leads/[id]/route.ts)
|
||||||
|
- [route.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/leads/[id]/followups/route.ts)
|
||||||
|
|
||||||
|
## Service Behavior
|
||||||
|
|
||||||
|
- `crm_leads` is now the source of truth for lead CRUD
|
||||||
|
- lead code generation reuses `generateNextDocumentCode()` with `documentType = crm_lead`
|
||||||
|
- lead detail returns `suggestedSalesOwnerId` from `crm_customers.owner_user_id` when a linked customer exists
|
||||||
|
- route handlers do not query Drizzle directly
|
||||||
|
- access is enforced in the service layer through resolved CRM access converted into `CrmSecurityContext`
|
||||||
|
|
||||||
|
## Follow-up Persistence Decision
|
||||||
|
|
||||||
|
Current schema has:
|
||||||
|
|
||||||
|
- `crm_leads`
|
||||||
|
- `crm_enquiry_followups`
|
||||||
|
- no `crm_lead_followups`
|
||||||
|
|
||||||
|
Task D.5.2 also prohibits:
|
||||||
|
|
||||||
|
- new tables
|
||||||
|
- new migrations
|
||||||
|
|
||||||
|
Because of that, this slice implements lead follow-ups by reusing the audit foundation as the persistence stream for lead follow-up events:
|
||||||
|
|
||||||
|
- POST `/api/crm/leads/[id]/followups` writes `create_lead_followup`
|
||||||
|
- GET `/api/crm/leads/[id]/followups` reads those audit events back as lead follow-up history
|
||||||
|
- the latest follow-up status is synchronized into `crm_leads.followup_status`
|
||||||
|
|
||||||
|
This keeps D.5.2 inside its no-migration boundary while still making the API operational.
|
||||||
|
|
||||||
|
## Audit Actions
|
||||||
|
|
||||||
|
This task records:
|
||||||
|
|
||||||
|
- `create_lead`
|
||||||
|
- `update_lead`
|
||||||
|
- `delete_lead`
|
||||||
|
- `view_lead`
|
||||||
|
- `create_lead_followup`
|
||||||
|
|
||||||
|
## Intentional Non-Scope Preservation
|
||||||
|
|
||||||
|
This task did not change:
|
||||||
|
|
||||||
|
- enquiry CRUD
|
||||||
|
- enquiry assignment flow
|
||||||
|
- dashboard datasets
|
||||||
|
- reporting datasets
|
||||||
|
- won/lost synchronization
|
||||||
|
- `crm_enquiries.pipeline_stage`
|
||||||
|
- lead UI pages that still point at the legacy enquiry-based workspace
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Planned verification target for this slice:
|
||||||
|
|
||||||
|
- `npm exec tsc --noEmit`
|
||||||
|
|
||||||
|
## Notes For D.5.3+
|
||||||
|
|
||||||
|
- lead UI still needs to migrate away from enquiry-backed components
|
||||||
|
- lead assignment and lead-to-enquiry conversion remain for later D.5 phases
|
||||||
|
- if future phases need richer editable follow-up history than audit-backed event replay, a dedicated ADR-backed persistence decision is still needed
|
||||||
@@ -72,7 +72,7 @@ Before changing any feature, foundation, report, PDF flow, or authorization beha
|
|||||||
| ADR-0015 Customer Ownership and Contact Sharing | Task C.1, Task L.3.1 |
|
| ADR-0015 Customer Ownership and Contact Sharing | Task C.1, Task L.3.1 |
|
||||||
| ADR-0016 Won / Lost Lifecycle Governance | Task D.4, Task J, Task K.2 |
|
| ADR-0016 Won / Lost Lifecycle Governance | Task D.4, Task J, Task K.2 |
|
||||||
| ADR-0017 CRM Report Foundation | Task K.1, Task K.2 |
|
| ADR-0017 CRM Report Foundation | Task K.1, Task K.2 |
|
||||||
| ADR-0018 Lead / Enquiry Domain Separation | Task D.5, Task D.5.1 |
|
| ADR-0018 Lead / Enquiry Domain Separation | Task D.5, Task D.5.1, Task D.5.2 |
|
||||||
|
|
||||||
## Task Dependency Map
|
## Task Dependency Map
|
||||||
|
|
||||||
@@ -89,6 +89,7 @@ Before changing any feature, foundation, report, PDF flow, or authorization beha
|
|||||||
- Task D.4 depends on: Task D, Task D.3
|
- Task D.4 depends on: Task D, Task D.3
|
||||||
- Task D.5 depends on: Task D.3, Task D.4, Task J, Task K.2, Task L.3
|
- Task D.5 depends on: Task D.3, Task D.4, Task J, Task K.2, Task L.3
|
||||||
- Task D.5.1 depends on: Task D.5
|
- Task D.5.1 depends on: Task D.5
|
||||||
|
- Task D.5.2 depends on: Task D.5.1
|
||||||
- Task E depends on: Task D, Task B foundations
|
- Task E depends on: Task D, Task B foundations
|
||||||
- Task E.1 depends on: Task E
|
- Task E.1 depends on: Task E
|
||||||
- Task F depends on: Task E, Task E.1
|
- Task F depends on: Task E, Task E.1
|
||||||
@@ -299,6 +300,19 @@ Before changing any feature, foundation, report, PDF flow, or authorization beha
|
|||||||
- Key Files: `docs/implementation/task-d51-lead-foundation-stabilization.md`, `docs/standards/project-foundations.md`, `docs/standards/task-catalog.md`, `src/features/foundation/master-options/types.ts`
|
- Key Files: `docs/implementation/task-d51-lead-foundation-stabilization.md`, `docs/standards/project-foundations.md`, `docs/standards/task-catalog.md`, `src/features/foundation/master-options/types.ts`
|
||||||
- Read Before Modify: `task-d51-lead-foundation-stabilization.md`, `0018-lead-enquiry-domain-separation.md`, `task-d5-lead-enquiry-domain-separation.md`, `src/db/schema.ts`, `src/db/seeds/foundation.seed.ts`
|
- Read Before Modify: `task-d51-lead-foundation-stabilization.md`, `0018-lead-enquiry-domain-separation.md`, `task-d5-lead-enquiry-domain-separation.md`, `src/db/schema.ts`, `src/db/seeds/foundation.seed.ts`
|
||||||
|
|
||||||
|
### Task D.5.2
|
||||||
|
|
||||||
|
- Task ID: `Task D.5.2`
|
||||||
|
- Task Name: `Lead Domain Service and API Foundation`
|
||||||
|
- Status: `Completed`
|
||||||
|
- Objective: Create the first production lead service and API slice on top of `crm_leads` while preserving legacy enquiry behavior.
|
||||||
|
- Related ADRs: `ADR-0018`
|
||||||
|
- Foundations Created: `None`
|
||||||
|
- Foundations Modified: `Lead Foundation`, `Audit Foundation`, `Document Sequence Foundation`, `Authorization Foundation` reuse path
|
||||||
|
- Major Deliverables: lead CRUD service, `/api/crm/leads/**`, lead follow-up API backed by audit events, lead reference-data integration, customer-owner suggestion output.
|
||||||
|
- Key Files: `docs/implementation/task-d52-lead-domain-service-api-foundation.md`, `src/features/crm/leads/**`, `src/app/api/crm/leads/**`
|
||||||
|
- Read Before Modify: `task-d52-lead-domain-service-api-foundation.md`, `task-d51-lead-foundation-stabilization.md`, `0018-lead-enquiry-domain-separation.md`, `task-d5-lead-enquiry-domain-separation.md`
|
||||||
|
|
||||||
### Task E
|
### Task E
|
||||||
|
|
||||||
- Task ID: `Task E`
|
- Task ID: `Task E`
|
||||||
|
|||||||
489
plans/task-d.5.2.md
Normal file
489
plans/task-d.5.2.md
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
# Task D.5.2: Lead Domain Service & API Foundation
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Planned
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Build the production Lead service layer and API foundation on top of the existing D.5.1 Lead Foundation.
|
||||||
|
|
||||||
|
This task introduces Lead as a first-class CRM domain without changing assignment workflows, dashboard datasets, reports, or legacy enquiry behavior.
|
||||||
|
|
||||||
|
The objective is to:
|
||||||
|
|
||||||
|
* establish Lead service architecture
|
||||||
|
* establish Lead API contracts
|
||||||
|
* establish Lead security boundaries
|
||||||
|
* establish Lead follow-up lifecycle handling
|
||||||
|
* prepare D.5.3 Lead Assignment → Create Enquiry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mandatory Review
|
||||||
|
|
||||||
|
Review before implementation:
|
||||||
|
|
||||||
|
* `AGENTS.md`
|
||||||
|
* `docs/standards/project-foundations.md`
|
||||||
|
* `docs/standards/task-catalog.md`
|
||||||
|
* `docs/adr/0018-lead-enquiry-domain-separation.md`
|
||||||
|
* `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
|
||||||
|
* `docs/implementation/task-d51-lead-foundation-stabilization-output-completion.md`
|
||||||
|
* Customer Ownership Foundation
|
||||||
|
* Authorization Foundation
|
||||||
|
* Audit Foundation
|
||||||
|
* Master Option Foundation
|
||||||
|
* Document Sequence Foundation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Foundation Rules
|
||||||
|
|
||||||
|
The following already exist and must be reused:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_leads
|
||||||
|
crm_enquiries.lead_id
|
||||||
|
|
||||||
|
crm_lead_awareness
|
||||||
|
crm_lead_status
|
||||||
|
crm_lead_followup_status
|
||||||
|
crm_lead_lost_reason
|
||||||
|
|
||||||
|
crm_lead document sequence
|
||||||
|
crm_enquiry document sequence
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not recreate:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
tables
|
||||||
|
migrations
|
||||||
|
seed categories
|
||||||
|
document sequence configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.1 Lead Service Layer
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/leads/server/service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Required operations:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
listLeads()
|
||||||
|
getLeadById()
|
||||||
|
createLead()
|
||||||
|
updateLead()
|
||||||
|
deleteLead()
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Use `crm_leads` as the Lead source of truth
|
||||||
|
* No direct DB access from route handlers
|
||||||
|
* Keep legacy enquiry behavior unchanged
|
||||||
|
* Do not create enquiry from lead in this task
|
||||||
|
|
||||||
|
Reuse:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Foundation
|
||||||
|
Audit Foundation
|
||||||
|
Authorization Foundation
|
||||||
|
Document Sequence Foundation
|
||||||
|
Master Option Foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.2 Lead DTO Contracts
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/leads/types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Request contracts:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
CreateLeadInput
|
||||||
|
UpdateLeadInput
|
||||||
|
LeadListFilters
|
||||||
|
CreateLeadFollowupInput
|
||||||
|
```
|
||||||
|
|
||||||
|
Response contracts:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
LeadSummary
|
||||||
|
LeadDetail
|
||||||
|
LeadListResponse
|
||||||
|
LeadFollowupSummary
|
||||||
|
```
|
||||||
|
|
||||||
|
Lead Detail must include:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
suggestedSalesOwnerId
|
||||||
|
```
|
||||||
|
|
||||||
|
when customer owner exists.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Do not auto assign.
|
||||||
|
Do not create enquiry.
|
||||||
|
This is suggestion only.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.3 Lead Code Generation
|
||||||
|
|
||||||
|
Use existing document sequence foundation.
|
||||||
|
|
||||||
|
Document type:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_lead
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected format:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
LD2606-001
|
||||||
|
```
|
||||||
|
|
||||||
|
No custom numbering logic allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.4 Lead API Routes
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/crm/leads
|
||||||
|
POST /api/crm/leads
|
||||||
|
|
||||||
|
GET /api/crm/leads/[id]
|
||||||
|
PATCH /api/crm/leads/[id]
|
||||||
|
DELETE /api/crm/leads/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes must call:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Lead Service Layer
|
||||||
|
```
|
||||||
|
|
||||||
|
Route handlers must not query Drizzle directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.5 Lead Follow-up Foundation
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/crm/leads/[id]/followups
|
||||||
|
POST /api/crm/leads/[id]/followups
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Reuse existing CRM follow-up pattern
|
||||||
|
* Do not create a second follow-up architecture
|
||||||
|
* Use lead follow-up status from master options
|
||||||
|
* Keep follow-up scoped to organization and CRM access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.6 Lead Security
|
||||||
|
|
||||||
|
Reuse:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
resolveCrmMembershipAccess()
|
||||||
|
```
|
||||||
|
|
||||||
|
Visibility:
|
||||||
|
|
||||||
|
Marketing:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
own leads
|
||||||
|
```
|
||||||
|
|
||||||
|
Managers:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
team scope
|
||||||
|
```
|
||||||
|
|
||||||
|
Admins:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
organization scope
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Do not check role strings directly
|
||||||
|
* Use resolved CRM access
|
||||||
|
* Enforce security in service layer
|
||||||
|
* UI visibility alone is not enough
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.7 Customer Ownership Integration
|
||||||
|
|
||||||
|
When customer owner exists:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
customer.ownerUserId
|
||||||
|
```
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
suggestedSalesOwnerId
|
||||||
|
```
|
||||||
|
|
||||||
|
in Lead Detail.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
* Do not auto assign
|
||||||
|
* Do not create enquiry
|
||||||
|
* Do not update `crm_enquiries`
|
||||||
|
* This is suggestion only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.8 Master Option Integration
|
||||||
|
|
||||||
|
Lead APIs must expose options through existing master-option infrastructure:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
awareness
|
||||||
|
status
|
||||||
|
followupStatus
|
||||||
|
lostReason
|
||||||
|
```
|
||||||
|
|
||||||
|
Use categories:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_lead_awareness
|
||||||
|
crm_lead_status
|
||||||
|
crm_lead_followup_status
|
||||||
|
crm_lead_lost_reason
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* No hardcoded labels
|
||||||
|
* Option values must come from foundation master options
|
||||||
|
* Keep inactive options hidden from normal API responses unless explicitly requested
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.9 Audit Logging
|
||||||
|
|
||||||
|
Reuse:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
auditCreate()
|
||||||
|
auditUpdate()
|
||||||
|
auditDelete()
|
||||||
|
auditAction()
|
||||||
|
```
|
||||||
|
|
||||||
|
Required audit actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
create_lead
|
||||||
|
update_lead
|
||||||
|
delete_lead
|
||||||
|
view_lead
|
||||||
|
create_lead_followup
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Audit every mutation
|
||||||
|
* Audit lead detail view if existing audit convention supports view logging
|
||||||
|
* Do not introduce a separate audit pattern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope D5.2.10 Legacy Compatibility
|
||||||
|
|
||||||
|
Keep current system operational.
|
||||||
|
|
||||||
|
Current legacy behavior:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_enquiries.pipeline_stage
|
||||||
|
```
|
||||||
|
|
||||||
|
must remain untouched.
|
||||||
|
|
||||||
|
Do not:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
change enquiry services
|
||||||
|
change assignment flow
|
||||||
|
change dashboard
|
||||||
|
change reports
|
||||||
|
change won/lost synchronization
|
||||||
|
```
|
||||||
|
|
||||||
|
Those belong to later D.5 phases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
New:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/leads/**
|
||||||
|
```
|
||||||
|
|
||||||
|
New APIs:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/api/crm/leads
|
||||||
|
/api/crm/leads/[id]
|
||||||
|
/api/crm/leads/[id]/followups
|
||||||
|
```
|
||||||
|
|
||||||
|
Documentation:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/task-d52-lead-domain-service-api-foundation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Outputs
|
||||||
|
|
||||||
|
Lead Service:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
CRUD operational
|
||||||
|
```
|
||||||
|
|
||||||
|
Lead API:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
CRUD operational
|
||||||
|
```
|
||||||
|
|
||||||
|
Lead Follow-up:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
API operational
|
||||||
|
```
|
||||||
|
|
||||||
|
Security:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
resolved CRM access enforced
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
lead actions recorded
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy CRM:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explicit Non-Scope
|
||||||
|
|
||||||
|
Do not:
|
||||||
|
|
||||||
|
* create Lead UI
|
||||||
|
* create Lead assignment flow
|
||||||
|
* create Lead → Enquiry conversion
|
||||||
|
* create Enquiry from Lead
|
||||||
|
* update dashboard
|
||||||
|
* update reports
|
||||||
|
* migrate legacy data
|
||||||
|
* remove `pipeline_stage`
|
||||||
|
* change existing enquiry APIs
|
||||||
|
* change quotation conversion behavior
|
||||||
|
|
||||||
|
These belong to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
D.5.3+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
npm exec tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Create Lead
|
||||||
|
Update Lead
|
||||||
|
Delete Lead
|
||||||
|
View Lead
|
||||||
|
List Lead
|
||||||
|
Lead code generation
|
||||||
|
Lead follow-up creation
|
||||||
|
Lead follow-up listing
|
||||||
|
Audit logging
|
||||||
|
Current Enquiry APIs still function
|
||||||
|
Current Dashboard still functions
|
||||||
|
Current Reports still function
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Task is complete when:
|
||||||
|
|
||||||
|
* Lead service layer exists
|
||||||
|
* Lead CRUD APIs exist
|
||||||
|
* Lead follow-up APIs exist
|
||||||
|
* Lead security uses resolved CRM access
|
||||||
|
* Lead code generation uses sequence foundation
|
||||||
|
* Lead master options use foundation categories
|
||||||
|
* Audit logging exists
|
||||||
|
* Existing CRM functionality remains unchanged
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Lead Domain Service = Established
|
||||||
|
Lead API Foundation = Established
|
||||||
|
Ready for D.5.3 Lead Assignment → Create Enquiry
|
||||||
|
```
|
||||||
94
src/app/api/crm/leads/[id]/followups/route.ts
Normal file
94
src/app/api/crm/leads/[id]/followups/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { leadFollowupSchema } from '@/features/crm/leads/schemas/lead.schema';
|
||||||
|
import {
|
||||||
|
buildLeadAccessContext,
|
||||||
|
createLeadFollowup,
|
||||||
|
listLeadFollowups
|
||||||
|
} from '@/features/crm/leads/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, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmLeadRead
|
||||||
|
});
|
||||||
|
const accessContext = buildLeadAccessContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const items = await listLeadFollowups(id, organization.id, accessContext);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Lead follow-ups loaded successfully',
|
||||||
|
items
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to load lead follow-ups' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmLeadUpdate
|
||||||
|
});
|
||||||
|
const payload = leadFollowupSchema.parse(await request.json());
|
||||||
|
const accessContext = buildLeadAccessContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const created = await createLeadFollowup(
|
||||||
|
id,
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Lead follow-up created successfully',
|
||||||
|
followup: created
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to create lead follow-up' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/app/api/crm/leads/[id]/route.ts
Normal file
167
src/app/api/crm/leads/[id]/route.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { updateLeadSchema } from '@/features/crm/leads/schemas/lead.schema';
|
||||||
|
import {
|
||||||
|
buildLeadAccessContext,
|
||||||
|
deleteLead,
|
||||||
|
getLeadById,
|
||||||
|
getLeadReferenceData,
|
||||||
|
listLeadFollowups,
|
||||||
|
updateLead
|
||||||
|
} from '@/features/crm/leads/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, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmLeadRead
|
||||||
|
});
|
||||||
|
const accessContext = buildLeadAccessContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const [lead, followups, referenceData] = await Promise.all([
|
||||||
|
getLeadById(id, organization.id, accessContext),
|
||||||
|
listLeadFollowups(id, organization.id, accessContext),
|
||||||
|
getLeadReferenceData(organization.id)
|
||||||
|
]);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: lead.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_lead',
|
||||||
|
entityId: id,
|
||||||
|
action: 'view_lead',
|
||||||
|
afterData: {
|
||||||
|
code: lead.code
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Lead loaded successfully',
|
||||||
|
lead,
|
||||||
|
followups,
|
||||||
|
referenceData
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to load lead' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmLeadUpdate
|
||||||
|
});
|
||||||
|
const payload = updateLeadSchema.parse(await request.json());
|
||||||
|
const accessContext = buildLeadAccessContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const before = await getLeadById(id, organization.id, accessContext);
|
||||||
|
const updated = await updateLead(
|
||||||
|
id,
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: updated.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_lead',
|
||||||
|
entityId: id,
|
||||||
|
action: 'update_lead',
|
||||||
|
beforeData: before,
|
||||||
|
afterData: updated
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Lead updated successfully',
|
||||||
|
lead: updated
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to update lead' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmLeadDelete
|
||||||
|
});
|
||||||
|
const accessContext = buildLeadAccessContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const before = await getLeadById(id, organization.id, accessContext);
|
||||||
|
const deleted = await deleteLead(id, organization.id, accessContext);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: deleted.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_lead',
|
||||||
|
entityId: id,
|
||||||
|
action: 'delete_lead',
|
||||||
|
beforeData: before,
|
||||||
|
afterData: deleted
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Lead deleted successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to delete lead' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/app/api/crm/leads/route.ts
Normal file
125
src/app/api/crm/leads/route.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { leadSchema } from '@/features/crm/leads/schemas/lead.schema';
|
||||||
|
import {
|
||||||
|
buildLeadAccessContext,
|
||||||
|
createLead,
|
||||||
|
getLeadReferenceData,
|
||||||
|
listLeads
|
||||||
|
} from '@/features/crm/leads/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmLeadRead
|
||||||
|
});
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const page = Number(searchParams.get('page') ?? 1);
|
||||||
|
const limit = Number(searchParams.get('limit') ?? 10);
|
||||||
|
const accessContext = buildLeadAccessContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const [result, referenceData] = await Promise.all([
|
||||||
|
listLeads(
|
||||||
|
organization.id,
|
||||||
|
{
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
search: searchParams.get('search') ?? undefined,
|
||||||
|
branchId: searchParams.get('branchId') ?? undefined,
|
||||||
|
customerId: searchParams.get('customerId') ?? undefined,
|
||||||
|
status: searchParams.get('status') ?? undefined,
|
||||||
|
awarenessId: searchParams.get('awarenessId') ?? undefined,
|
||||||
|
followupStatus: searchParams.get('followupStatus') ?? undefined,
|
||||||
|
outcome: searchParams.get('outcome') ?? undefined,
|
||||||
|
ownerMarketingUserId: searchParams.get('ownerMarketingUserId') ?? undefined,
|
||||||
|
sort: searchParams.get('sort') ?? undefined
|
||||||
|
},
|
||||||
|
accessContext
|
||||||
|
),
|
||||||
|
getLeadReferenceData(organization.id)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Leads loaded successfully',
|
||||||
|
totalItems: result.totalItems,
|
||||||
|
offset: (page - 1) * limit,
|
||||||
|
limit,
|
||||||
|
items: result.items,
|
||||||
|
referenceData
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to load leads' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmLeadCreate
|
||||||
|
});
|
||||||
|
const payload = leadSchema.parse(await request.json());
|
||||||
|
const accessContext = buildLeadAccessContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const created = await createLead(
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: created.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_lead',
|
||||||
|
entityId: created.id,
|
||||||
|
action: 'create_lead',
|
||||||
|
afterData: created
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Lead created successfully',
|
||||||
|
lead: created
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to create lead' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/features/crm/leads/schemas/lead.schema.ts
Normal file
24
src/features/crm/leads/schemas/lead.schema.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
export const leadSchema = z.object({
|
||||||
|
branchId: z.string().nullable().optional(),
|
||||||
|
customerId: z.string().nullable().optional(),
|
||||||
|
contactId: z.string().nullable().optional(),
|
||||||
|
projectName: z.string().nullable().optional(),
|
||||||
|
projectLocation: z.string().nullable().optional(),
|
||||||
|
awarenessId: z.string().nullable().optional(),
|
||||||
|
status: z.string().min(1, 'Please select a lead status'),
|
||||||
|
followupStatus: z.string().nullable().optional(),
|
||||||
|
lostReason: z.string().nullable().optional(),
|
||||||
|
outcome: z.enum(['open', 'won', 'lost']).default('open'),
|
||||||
|
ownerMarketingUserId: z.string().nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateLeadSchema = leadSchema.partial();
|
||||||
|
|
||||||
|
export const leadFollowupSchema = z.object({
|
||||||
|
followupDate: z.string().min(1, 'Follow-up date is required'),
|
||||||
|
followupStatus: z.string().min(1, 'Please select a follow-up status'),
|
||||||
|
note: z.string().nullable().optional(),
|
||||||
|
nextFollowupDate: z.string().nullable().optional()
|
||||||
|
});
|
||||||
814
src/features/crm/leads/server/service.ts
Normal file
814
src/features/crm/leads/server/service.ts
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
or,
|
||||||
|
type SQL
|
||||||
|
} from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
crmCustomerContacts,
|
||||||
|
crmCustomers,
|
||||||
|
crmEnquiries,
|
||||||
|
crmLeads,
|
||||||
|
memberships,
|
||||||
|
trAuditLogs,
|
||||||
|
users
|
||||||
|
} from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||||
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
import {
|
||||||
|
buildCrmSecurityContext,
|
||||||
|
canAccessCustomer,
|
||||||
|
canAccessScopedCrmRecord,
|
||||||
|
type CrmSecurityContext
|
||||||
|
} from '@/features/crm/security/server/service';
|
||||||
|
import type {
|
||||||
|
CreateLeadFollowupInput,
|
||||||
|
CreateLeadInput,
|
||||||
|
LeadBranchOption,
|
||||||
|
LeadDetail,
|
||||||
|
LeadFollowupSummary,
|
||||||
|
LeadListFilters,
|
||||||
|
LeadListResponse,
|
||||||
|
LeadOption,
|
||||||
|
LeadReferenceData,
|
||||||
|
LeadSummary,
|
||||||
|
UpdateLeadInput
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const LEAD_OPTION_CATEGORIES = {
|
||||||
|
awareness: 'crm_lead_awareness',
|
||||||
|
status: 'crm_lead_status',
|
||||||
|
followupStatus: 'crm_lead_followup_status',
|
||||||
|
lostReason: 'crm_lead_lost_reason'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LeadAccessContext = CrmSecurityContext;
|
||||||
|
|
||||||
|
type LeadRow = typeof crmLeads.$inferSelect;
|
||||||
|
|
||||||
|
type LeadLookupMaps = {
|
||||||
|
awarenessLabels: Map<string, string>;
|
||||||
|
statusLabels: Map<string, string>;
|
||||||
|
followupStatusLabels: Map<string, string>;
|
||||||
|
lostReasonLabels: Map<string, string>;
|
||||||
|
customerNames: Map<string, string>;
|
||||||
|
contactNames: Map<string, string>;
|
||||||
|
userNames: Map<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LeadFollowupAuditPayload = {
|
||||||
|
id: string;
|
||||||
|
leadId: string;
|
||||||
|
followupDate: string;
|
||||||
|
followupStatus: string;
|
||||||
|
note: string | null;
|
||||||
|
nextFollowupDate: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapOption(
|
||||||
|
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||||
|
): LeadOption {
|
||||||
|
return {
|
||||||
|
id: option.id,
|
||||||
|
code: option.code,
|
||||||
|
label: option.label,
|
||||||
|
value: option.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBranch(
|
||||||
|
branch: Awaited<ReturnType<typeof getUserBranches>>[number]
|
||||||
|
): LeadBranchOption {
|
||||||
|
return {
|
||||||
|
id: branch.id,
|
||||||
|
code: branch.code,
|
||||||
|
name: branch.name,
|
||||||
|
value: branch.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDateTime(value: Date | null | undefined) {
|
||||||
|
return value ? value.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLeadOutcome(value?: string | null): 'open' | 'won' | 'lost' {
|
||||||
|
if (value === 'won' || value === 'lost') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'open';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
branchId: row.branchId ?? null,
|
||||||
|
code: row.code,
|
||||||
|
customerId: row.customerId ?? null,
|
||||||
|
customerName: row.customerId ? (lookups.customerNames.get(row.customerId) ?? null) : null,
|
||||||
|
contactId: row.contactId ?? null,
|
||||||
|
contactName: row.contactId ? (lookups.contactNames.get(row.contactId) ?? null) : null,
|
||||||
|
projectName: row.projectName ?? null,
|
||||||
|
projectLocation: row.projectLocation ?? null,
|
||||||
|
awarenessId: row.awarenessId ?? null,
|
||||||
|
awarenessLabel: row.awarenessId ? (lookups.awarenessLabels.get(row.awarenessId) ?? null) : null,
|
||||||
|
status: row.status,
|
||||||
|
statusLabel: lookups.statusLabels.get(row.status) ?? null,
|
||||||
|
followupStatus: row.followupStatus ?? null,
|
||||||
|
followupStatusLabel: row.followupStatus
|
||||||
|
? (lookups.followupStatusLabels.get(row.followupStatus) ?? null)
|
||||||
|
: null,
|
||||||
|
lostReason: row.lostReason ?? null,
|
||||||
|
lostReasonLabel: row.lostReason ? (lookups.lostReasonLabels.get(row.lostReason) ?? null) : null,
|
||||||
|
outcome: row.outcome,
|
||||||
|
ownerMarketingUserId: row.ownerMarketingUserId ?? null,
|
||||||
|
ownerMarketingName: row.ownerMarketingUserId
|
||||||
|
? (lookups.userNames.get(row.ownerMarketingUserId) ?? null)
|
||||||
|
: null,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: toIsoDateTime(row.deletedAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLeadDetail(
|
||||||
|
row: LeadRow,
|
||||||
|
lookups: LeadLookupMaps,
|
||||||
|
suggestedSalesOwnerId: string | null,
|
||||||
|
relatedEnquiryCount: number
|
||||||
|
): LeadDetail {
|
||||||
|
return {
|
||||||
|
...mapLeadSummary(row, lookups),
|
||||||
|
suggestedSalesOwnerId,
|
||||||
|
relatedEnquiryCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSort(sort?: string) {
|
||||||
|
if (!sort) {
|
||||||
|
return desc(crmLeads.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
||||||
|
const [rule] = parsed;
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return desc(crmLeads.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortMap = {
|
||||||
|
code: crmLeads.code,
|
||||||
|
projectName: crmLeads.projectName,
|
||||||
|
status: crmLeads.status,
|
||||||
|
followupStatus: crmLeads.followupStatus,
|
||||||
|
outcome: crmLeads.outcome,
|
||||||
|
updatedAt: crmLeads.updatedAt,
|
||||||
|
createdAt: crmLeads.createdAt
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const column = sortMap[rule.id as keyof typeof sortMap];
|
||||||
|
|
||||||
|
if (!column) {
|
||||||
|
return desc(crmLeads.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule.desc ? desc(column) : asc(column);
|
||||||
|
} catch {
|
||||||
|
return desc(crmLeads.updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveValidOptionIds(category: string, organizationId: string) {
|
||||||
|
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||||
|
return new Set(options.map((option) => option.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertMasterOptionValue(
|
||||||
|
organizationId: string,
|
||||||
|
category: string,
|
||||||
|
optionId?: string | null
|
||||||
|
) {
|
||||||
|
if (!optionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validIds = await resolveValidOptionIds(category, organizationId);
|
||||||
|
|
||||||
|
if (!validIds.has(optionId)) {
|
||||||
|
throw new AuthError(`Invalid option for ${category}`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
||||||
|
const [customer] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmCustomers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomers.id, id),
|
||||||
|
eq(crmCustomers.organizationId, organizationId),
|
||||||
|
isNull(crmCustomers.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
throw new AuthError('Customer not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertContactBelongsToOrganization(
|
||||||
|
contactId: string,
|
||||||
|
organizationId: string,
|
||||||
|
customerId?: string | null
|
||||||
|
) {
|
||||||
|
const [contact] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmCustomerContacts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomerContacts.id, contactId),
|
||||||
|
eq(crmCustomerContacts.organizationId, organizationId),
|
||||||
|
isNull(crmCustomerContacts.deletedAt),
|
||||||
|
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
throw new AuthError('Contact not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertMembershipUserBelongsToOrganization(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string
|
||||||
|
) {
|
||||||
|
const [membership] = await db
|
||||||
|
.select({ userId: memberships.userId })
|
||||||
|
.from(memberships)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(memberships.userId, userId),
|
||||||
|
eq(memberships.organizationId, organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new AuthError('Assigned user not found in organization', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccessLeadRow(row: LeadRow, accessContext: LeadAccessContext) {
|
||||||
|
return canAccessScopedCrmRecord(accessContext, {
|
||||||
|
branchId: row.branchId,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
ownerUserId: row.ownerMarketingUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccessScopedFilters(context: LeadAccessContext): SQL[] {
|
||||||
|
const filters: SQL[] = [];
|
||||||
|
|
||||||
|
if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) {
|
||||||
|
filters.push(inArray(crmLeads.branchId, context.branchScopeIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.branchScopeMode === 'none') {
|
||||||
|
filters.push(eq(crmLeads.id, '__forbidden__'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.membershipRole !== 'admin' && context.ownershipScope === 'own') {
|
||||||
|
filters.push(
|
||||||
|
or(
|
||||||
|
eq(crmLeads.createdBy, context.userId),
|
||||||
|
eq(crmLeads.ownerMarketingUserId, context.userId)
|
||||||
|
)!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertLeadBelongsToOrganization(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: LeadAccessContext
|
||||||
|
) {
|
||||||
|
const [lead] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmLeads)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmLeads.id, id),
|
||||||
|
eq(crmLeads.organizationId, organizationId),
|
||||||
|
isNull(crmLeads.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!lead) {
|
||||||
|
throw new AuthError('Lead not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessContext && !canAccessLeadRow(lead, accessContext)) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lead;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise<LeadLookupMaps> {
|
||||||
|
const [awarenesses, statuses, followupStatuses, lostReasons] = await Promise.all([
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId })
|
||||||
|
]);
|
||||||
|
const customerIds = [
|
||||||
|
...new Set(rows.map((row) => row.customerId).filter((value): value is string => Boolean(value)))
|
||||||
|
];
|
||||||
|
const contactIds = [
|
||||||
|
...new Set(rows.map((row) => row.contactId).filter((value): value is string => Boolean(value)))
|
||||||
|
];
|
||||||
|
const userIds = [
|
||||||
|
...new Set(
|
||||||
|
rows
|
||||||
|
.flatMap((row) => [row.ownerMarketingUserId, row.createdBy])
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
const [customerRows, contactRows, userRows] = await Promise.all([
|
||||||
|
customerIds.length
|
||||||
|
? db
|
||||||
|
.select({ id: crmCustomers.id, name: crmCustomers.name })
|
||||||
|
.from(crmCustomers)
|
||||||
|
.where(inArray(crmCustomers.id, customerIds))
|
||||||
|
: Promise.resolve([]),
|
||||||
|
contactIds.length
|
||||||
|
? db
|
||||||
|
.select({ id: crmCustomerContacts.id, name: crmCustomerContacts.name })
|
||||||
|
.from(crmCustomerContacts)
|
||||||
|
.where(inArray(crmCustomerContacts.id, contactIds))
|
||||||
|
: Promise.resolve([]),
|
||||||
|
userIds.length
|
||||||
|
? db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, userIds))
|
||||||
|
: Promise.resolve([])
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
awarenessLabels: new Map(awarenesses.map((item) => [item.id, item.label])),
|
||||||
|
statusLabels: new Map(statuses.map((item) => [item.id, item.label])),
|
||||||
|
followupStatusLabels: new Map(followupStatuses.map((item) => [item.id, item.label])),
|
||||||
|
lostReasonLabels: new Map(lostReasons.map((item) => [item.id, item.label])),
|
||||||
|
customerNames: new Map(customerRows.map((item) => [item.id, item.name])),
|
||||||
|
contactNames: new Map(contactRows.map((item) => [item.id, item.name])),
|
||||||
|
userNames: new Map(userRows.map((item) => [item.id, item.name]))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateLeadPayload(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: CreateLeadInput | UpdateLeadInput,
|
||||||
|
accessContext?: LeadAccessContext
|
||||||
|
) {
|
||||||
|
await Promise.all([
|
||||||
|
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.awareness, payload.awarenessId),
|
||||||
|
payload.status
|
||||||
|
? assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.status, payload.status)
|
||||||
|
: Promise.resolve(),
|
||||||
|
assertMasterOptionValue(
|
||||||
|
organizationId,
|
||||||
|
LEAD_OPTION_CATEGORIES.followupStatus,
|
||||||
|
payload.followupStatus
|
||||||
|
),
|
||||||
|
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.lostReason, payload.lostReason)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (payload.branchId) {
|
||||||
|
await validateBranchAccess(payload.branchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.customerId) {
|
||||||
|
const customer = await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
||||||
|
|
||||||
|
if (accessContext && !canAccessCustomer(accessContext, customer)) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.contactId) {
|
||||||
|
await assertContactBelongsToOrganization(
|
||||||
|
payload.contactId,
|
||||||
|
organizationId,
|
||||||
|
payload.customerId ?? undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.ownerMarketingUserId) {
|
||||||
|
await assertMembershipUserBelongsToOrganization(payload.ownerMarketingUserId, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessContext) {
|
||||||
|
const draftRow = {
|
||||||
|
branchId: payload.branchId ?? null,
|
||||||
|
createdBy: userId,
|
||||||
|
ownerMarketingUserId: payload.ownerMarketingUserId ?? userId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!canAccessScopedCrmRecord(accessContext, {
|
||||||
|
branchId: draftRow.branchId,
|
||||||
|
createdBy: draftRow.createdBy,
|
||||||
|
ownerUserId: draftRow.ownerMarketingUserId
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLeadReferenceData(organizationId: string): Promise<LeadReferenceData> {
|
||||||
|
const [awarenesses, statuses, followupStatuses, lostReasons, branches] = await Promise.all([
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||||
|
getUserBranches()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
awarenesses: awarenesses.map(mapOption),
|
||||||
|
statuses: statuses.map(mapOption),
|
||||||
|
followupStatuses: followupStatuses.map(mapOption),
|
||||||
|
lostReasons: lostReasons.map(mapOption),
|
||||||
|
branches: branches.map(mapBranch)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLeads(
|
||||||
|
organizationId: string,
|
||||||
|
filters: LeadListFilters = {},
|
||||||
|
accessContext?: LeadAccessContext
|
||||||
|
): Promise<LeadListResponse> {
|
||||||
|
const page = filters.page ?? 1;
|
||||||
|
const limit = filters.limit ?? 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const whereFilters: SQL[] = [
|
||||||
|
eq(crmLeads.organizationId, organizationId),
|
||||||
|
isNull(crmLeads.deletedAt),
|
||||||
|
...(filters.branchId ? [eq(crmLeads.branchId, filters.branchId)] : []),
|
||||||
|
...(filters.customerId ? [eq(crmLeads.customerId, filters.customerId)] : []),
|
||||||
|
...(filters.status ? [eq(crmLeads.status, filters.status)] : []),
|
||||||
|
...(filters.awarenessId ? [eq(crmLeads.awarenessId, filters.awarenessId)] : []),
|
||||||
|
...(filters.followupStatus ? [eq(crmLeads.followupStatus, filters.followupStatus)] : []),
|
||||||
|
...(filters.outcome ? [eq(crmLeads.outcome, filters.outcome)] : []),
|
||||||
|
...(filters.ownerMarketingUserId
|
||||||
|
? [eq(crmLeads.ownerMarketingUserId, filters.ownerMarketingUserId)]
|
||||||
|
: []),
|
||||||
|
...(filters.search
|
||||||
|
? [
|
||||||
|
or(
|
||||||
|
ilike(crmLeads.code, `%${filters.search}%`),
|
||||||
|
ilike(crmLeads.projectName, `%${filters.search}%`),
|
||||||
|
ilike(crmLeads.projectLocation, `%${filters.search}%`)
|
||||||
|
)!
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(accessContext ? buildAccessScopedFilters(accessContext) : [])
|
||||||
|
];
|
||||||
|
const where = and(...whereFilters);
|
||||||
|
const [totalResult] = await db.select({ value: count() }).from(crmLeads).where(where);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(crmLeads)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(parseSort(filters.sort))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
const lookups = await buildLookupMaps(organizationId, rows);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => mapLeadSummary(row, lookups)),
|
||||||
|
totalItems: totalResult?.value ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLeadById(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: LeadAccessContext
|
||||||
|
): Promise<LeadDetail> {
|
||||||
|
const lead = await assertLeadBelongsToOrganization(id, organizationId, accessContext);
|
||||||
|
const [lookups, relatedEnquiryResult, customer] = await Promise.all([
|
||||||
|
buildLookupMaps(organizationId, [lead]),
|
||||||
|
db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(crmEnquiries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmEnquiries.organizationId, organizationId),
|
||||||
|
eq(crmEnquiries.leadId, id),
|
||||||
|
isNull(crmEnquiries.deletedAt)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
lead.customerId ? assertCustomerBelongsToOrganization(lead.customerId, organizationId) : Promise.resolve(null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return mapLeadDetail(
|
||||||
|
lead,
|
||||||
|
lookups,
|
||||||
|
customer?.ownerUserId ?? null,
|
||||||
|
relatedEnquiryResult[0]?.value ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLead(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: CreateLeadInput,
|
||||||
|
accessContext?: LeadAccessContext
|
||||||
|
): Promise<LeadDetail> {
|
||||||
|
await validateLeadPayload(organizationId, userId, payload, accessContext);
|
||||||
|
const codeResult = await generateNextDocumentCode({
|
||||||
|
organizationId,
|
||||||
|
documentType: 'crm_lead',
|
||||||
|
branchId: payload.branchId ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(crmLeads)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
branchId: payload.branchId ?? null,
|
||||||
|
code: codeResult.code,
|
||||||
|
customerId: payload.customerId ?? null,
|
||||||
|
contactId: payload.contactId ?? null,
|
||||||
|
projectName: payload.projectName?.trim() || null,
|
||||||
|
projectLocation: payload.projectLocation?.trim() || null,
|
||||||
|
awarenessId: payload.awarenessId ?? null,
|
||||||
|
status: payload.status,
|
||||||
|
followupStatus: payload.followupStatus ?? null,
|
||||||
|
lostReason: payload.lostReason ?? null,
|
||||||
|
outcome: normalizeLeadOutcome(payload.outcome),
|
||||||
|
ownerMarketingUserId: payload.ownerMarketingUserId ?? userId,
|
||||||
|
createdBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return getLeadById(created.id, organizationId, accessContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLead(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: UpdateLeadInput,
|
||||||
|
accessContext?: LeadAccessContext
|
||||||
|
): Promise<LeadDetail> {
|
||||||
|
const current = await assertLeadBelongsToOrganization(id, organizationId, accessContext);
|
||||||
|
const nextPayload = {
|
||||||
|
branchId: payload.branchId === undefined ? current.branchId : payload.branchId,
|
||||||
|
customerId: payload.customerId === undefined ? current.customerId : payload.customerId,
|
||||||
|
contactId:
|
||||||
|
payload.contactId === undefined
|
||||||
|
? current.contactId
|
||||||
|
: payload.contactId,
|
||||||
|
projectName: payload.projectName === undefined ? current.projectName : payload.projectName,
|
||||||
|
projectLocation:
|
||||||
|
payload.projectLocation === undefined ? current.projectLocation : payload.projectLocation,
|
||||||
|
awarenessId: payload.awarenessId === undefined ? current.awarenessId : payload.awarenessId,
|
||||||
|
status: payload.status ?? current.status,
|
||||||
|
followupStatus:
|
||||||
|
payload.followupStatus === undefined ? current.followupStatus : payload.followupStatus,
|
||||||
|
lostReason: payload.lostReason === undefined ? current.lostReason : payload.lostReason,
|
||||||
|
outcome: payload.outcome ?? normalizeLeadOutcome(current.outcome),
|
||||||
|
ownerMarketingUserId:
|
||||||
|
payload.ownerMarketingUserId === undefined
|
||||||
|
? current.ownerMarketingUserId
|
||||||
|
: payload.ownerMarketingUserId
|
||||||
|
} satisfies CreateLeadInput;
|
||||||
|
|
||||||
|
await validateLeadPayload(organizationId, userId, nextPayload, accessContext);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmLeads)
|
||||||
|
.set({
|
||||||
|
branchId: nextPayload.branchId ?? null,
|
||||||
|
customerId: nextPayload.customerId ?? null,
|
||||||
|
contactId: nextPayload.contactId ?? null,
|
||||||
|
projectName: nextPayload.projectName?.trim() || null,
|
||||||
|
projectLocation: nextPayload.projectLocation?.trim() || null,
|
||||||
|
awarenessId: nextPayload.awarenessId ?? null,
|
||||||
|
status: nextPayload.status,
|
||||||
|
followupStatus: nextPayload.followupStatus ?? null,
|
||||||
|
lostReason: nextPayload.lostReason ?? null,
|
||||||
|
outcome: normalizeLeadOutcome(nextPayload.outcome),
|
||||||
|
ownerMarketingUserId: nextPayload.ownerMarketingUserId ?? null,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmLeads.id, id));
|
||||||
|
|
||||||
|
return getLeadById(id, organizationId, accessContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLead(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: LeadAccessContext
|
||||||
|
): Promise<LeadDetail> {
|
||||||
|
await assertLeadBelongsToOrganization(id, organizationId, accessContext);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmLeads)
|
||||||
|
.set({
|
||||||
|
deletedAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.where(eq(crmLeads.id, id));
|
||||||
|
|
||||||
|
return getLeadById(id, organizationId, accessContext).catch(async () => {
|
||||||
|
const [deleted] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmLeads)
|
||||||
|
.where(eq(crmLeads.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const lookups = await buildLookupMaps(organizationId, deleted ? [deleted] : []);
|
||||||
|
|
||||||
|
return mapLeadDetail(deleted!, lookups, null, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLeadFollowups(
|
||||||
|
leadId: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: LeadAccessContext
|
||||||
|
): Promise<LeadFollowupSummary[]> {
|
||||||
|
await assertLeadBelongsToOrganization(leadId, organizationId, accessContext);
|
||||||
|
const [followupStatuses, rows] = await Promise.all([
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(trAuditLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(trAuditLogs.organizationId, organizationId),
|
||||||
|
eq(trAuditLogs.entityType, 'crm_lead'),
|
||||||
|
eq(trAuditLogs.entityId, leadId),
|
||||||
|
eq(trAuditLogs.action, 'create_lead_followup')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(trAuditLogs.createdAt))
|
||||||
|
]);
|
||||||
|
const userIds = [
|
||||||
|
...new Set(rows.map((row) => row.userId).filter((value): value is string => Boolean(value)))
|
||||||
|
];
|
||||||
|
const userRows = userIds.length
|
||||||
|
? await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, userIds))
|
||||||
|
: [];
|
||||||
|
const userNames = new Map(userRows.map((row) => [row.id, row.name]));
|
||||||
|
const followupStatusLabels = new Map(followupStatuses.map((item) => [item.id, item.label]));
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const payload = row.afterData as LeadFollowupAuditPayload | null;
|
||||||
|
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: payload.id,
|
||||||
|
leadId: payload.leadId,
|
||||||
|
followupDate: payload.followupDate,
|
||||||
|
followupStatus: payload.followupStatus,
|
||||||
|
followupStatusLabel: followupStatusLabels.get(payload.followupStatus) ?? null,
|
||||||
|
note: payload.note ?? null,
|
||||||
|
nextFollowupDate: payload.nextFollowupDate ?? null,
|
||||||
|
createdAt: payload.createdAt ?? row.createdAt.toISOString(),
|
||||||
|
createdBy: payload.createdBy ?? row.userId,
|
||||||
|
createdByName: userNames.get(payload.createdBy ?? row.userId) ?? null
|
||||||
|
} satisfies LeadFollowupSummary;
|
||||||
|
})
|
||||||
|
.filter((item): item is LeadFollowupSummary => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLeadFollowup(
|
||||||
|
leadId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: CreateLeadFollowupInput,
|
||||||
|
accessContext?: LeadAccessContext
|
||||||
|
): Promise<LeadFollowupSummary> {
|
||||||
|
const lead = await assertLeadBelongsToOrganization(leadId, organizationId, accessContext);
|
||||||
|
|
||||||
|
await assertMasterOptionValue(
|
||||||
|
organizationId,
|
||||||
|
LEAD_OPTION_CATEGORIES.followupStatus,
|
||||||
|
payload.followupStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const followupId = crypto.randomUUID();
|
||||||
|
const auditPayload: LeadFollowupAuditPayload = {
|
||||||
|
id: followupId,
|
||||||
|
leadId,
|
||||||
|
followupDate: new Date(payload.followupDate).toISOString(),
|
||||||
|
followupStatus: payload.followupStatus,
|
||||||
|
note: payload.note?.trim() || null,
|
||||||
|
nextFollowupDate: payload.nextFollowupDate
|
||||||
|
? new Date(payload.nextFollowupDate).toISOString()
|
||||||
|
: null,
|
||||||
|
createdBy: userId,
|
||||||
|
createdAt: now.toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmLeads)
|
||||||
|
.set({
|
||||||
|
followupStatus: payload.followupStatus,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.where(eq(crmLeads.id, lead.id));
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId,
|
||||||
|
branchId: lead.branchId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_lead',
|
||||||
|
entityId: leadId,
|
||||||
|
action: 'create_lead_followup',
|
||||||
|
afterData: auditPayload
|
||||||
|
});
|
||||||
|
|
||||||
|
const [userRow, followupStatuses] = await Promise.all([
|
||||||
|
db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId)
|
||||||
|
}),
|
||||||
|
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId })
|
||||||
|
]);
|
||||||
|
const labelMap = new Map(followupStatuses.map((item) => [item.id, item.label]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: followupId,
|
||||||
|
leadId,
|
||||||
|
followupDate: auditPayload.followupDate,
|
||||||
|
followupStatus: payload.followupStatus,
|
||||||
|
followupStatusLabel: labelMap.get(payload.followupStatus) ?? null,
|
||||||
|
note: auditPayload.note,
|
||||||
|
nextFollowupDate: auditPayload.nextFollowupDate,
|
||||||
|
createdAt: auditPayload.createdAt,
|
||||||
|
createdBy: userId,
|
||||||
|
createdByName: userRow?.name ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLeadAccessContext(input: {
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
membership: {
|
||||||
|
role: string;
|
||||||
|
businessRole: string;
|
||||||
|
businessRoles?: string[];
|
||||||
|
permissions?: string[] | null;
|
||||||
|
branchScopeIds?: string[] | null;
|
||||||
|
productTypeScopeIds?: string[] | null;
|
||||||
|
ownershipScope?: string | null;
|
||||||
|
branchScopeMode?: string | null;
|
||||||
|
productScopeMode?: string | null;
|
||||||
|
approvalAuthority?: string | null;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return buildCrmSecurityContext(input);
|
||||||
|
}
|
||||||
121
src/features/crm/leads/types.ts
Normal file
121
src/features/crm/leads/types.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
export interface LeadOption {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadBranchOption {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadReferenceData {
|
||||||
|
awarenesses: LeadOption[];
|
||||||
|
statuses: LeadOption[];
|
||||||
|
followupStatuses: LeadOption[];
|
||||||
|
lostReasons: LeadOption[];
|
||||||
|
branches: LeadBranchOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeadInput {
|
||||||
|
branchId?: string | null;
|
||||||
|
customerId?: string | null;
|
||||||
|
contactId?: string | null;
|
||||||
|
projectName?: string | null;
|
||||||
|
projectLocation?: string | null;
|
||||||
|
awarenessId?: string | null;
|
||||||
|
status: string;
|
||||||
|
followupStatus?: string | null;
|
||||||
|
lostReason?: string | null;
|
||||||
|
outcome?: 'open' | 'won' | 'lost';
|
||||||
|
ownerMarketingUserId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeadInput {
|
||||||
|
branchId?: string | null;
|
||||||
|
customerId?: string | null;
|
||||||
|
contactId?: string | null;
|
||||||
|
projectName?: string | null;
|
||||||
|
projectLocation?: string | null;
|
||||||
|
awarenessId?: string | null;
|
||||||
|
status?: string;
|
||||||
|
followupStatus?: string | null;
|
||||||
|
lostReason?: string | null;
|
||||||
|
outcome?: 'open' | 'won' | 'lost';
|
||||||
|
ownerMarketingUserId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadListFilters {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
branchId?: string;
|
||||||
|
customerId?: string;
|
||||||
|
status?: string;
|
||||||
|
awarenessId?: string;
|
||||||
|
followupStatus?: string;
|
||||||
|
outcome?: string;
|
||||||
|
ownerMarketingUserId?: string;
|
||||||
|
sort?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeadFollowupInput {
|
||||||
|
followupDate: string;
|
||||||
|
followupStatus: string;
|
||||||
|
note?: string | null;
|
||||||
|
nextFollowupDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadSummary {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
branchId: string | null;
|
||||||
|
code: string;
|
||||||
|
customerId: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
contactId: string | null;
|
||||||
|
contactName: string | null;
|
||||||
|
projectName: string | null;
|
||||||
|
projectLocation: string | null;
|
||||||
|
awarenessId: string | null;
|
||||||
|
awarenessLabel: string | null;
|
||||||
|
status: string;
|
||||||
|
statusLabel: string | null;
|
||||||
|
followupStatus: string | null;
|
||||||
|
followupStatusLabel: string | null;
|
||||||
|
lostReason: string | null;
|
||||||
|
lostReasonLabel: string | null;
|
||||||
|
outcome: string;
|
||||||
|
ownerMarketingUserId: string | null;
|
||||||
|
ownerMarketingName: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadDetail extends LeadSummary {
|
||||||
|
suggestedSalesOwnerId: string | null;
|
||||||
|
relatedEnquiryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadListResponse {
|
||||||
|
items: LeadSummary[];
|
||||||
|
totalItems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadFollowupSummary {
|
||||||
|
id: string;
|
||||||
|
leadId: string;
|
||||||
|
followupDate: string;
|
||||||
|
followupStatus: string;
|
||||||
|
followupStatusLabel: string | null;
|
||||||
|
note: string | null;
|
||||||
|
nextFollowupDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdByName: string | null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user