diff --git a/docs/implementation/task-d52-lead-domain-service-api-foundation.md b/docs/implementation/task-d52-lead-domain-service-api-foundation.md new file mode 100644 index 0000000..faf5d84 --- /dev/null +++ b/docs/implementation/task-d52-lead-domain-service-api-foundation.md @@ -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 diff --git a/docs/standards/task-catalog.md b/docs/standards/task-catalog.md index 7adbee0..1ac8087 100644 --- a/docs/standards/task-catalog.md +++ b/docs/standards/task-catalog.md @@ -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-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-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 @@ -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.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.2 depends on: Task D.5.1 - Task E depends on: Task D, Task B foundations - Task E.1 depends on: Task E - 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` - 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 ID: `Task E` diff --git a/plans/task-d.5.2.md b/plans/task-d.5.2.md new file mode 100644 index 0000000..fcec4a2 --- /dev/null +++ b/plans/task-d.5.2.md @@ -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 +``` diff --git a/src/app/api/crm/leads/[id]/followups/route.ts b/src/app/api/crm/leads/[id]/followups/route.ts new file mode 100644 index 0000000..694bdf8 --- /dev/null +++ b/src/app/api/crm/leads/[id]/followups/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/leads/[id]/route.ts b/src/app/api/crm/leads/[id]/route.ts new file mode 100644 index 0000000..5a1df2b --- /dev/null +++ b/src/app/api/crm/leads/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/leads/route.ts b/src/app/api/crm/leads/route.ts new file mode 100644 index 0000000..60d5f25 --- /dev/null +++ b/src/app/api/crm/leads/route.ts @@ -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 }); + } +} diff --git a/src/features/crm/leads/schemas/lead.schema.ts b/src/features/crm/leads/schemas/lead.schema.ts new file mode 100644 index 0000000..496386f --- /dev/null +++ b/src/features/crm/leads/schemas/lead.schema.ts @@ -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() +}); diff --git a/src/features/crm/leads/server/service.ts b/src/features/crm/leads/server/service.ts new file mode 100644 index 0000000..b5e15f7 --- /dev/null +++ b/src/features/crm/leads/server/service.ts @@ -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; + statusLabels: Map; + followupStatusLabels: Map; + lostReasonLabels: Map; + customerNames: Map; + contactNames: Map; + userNames: Map; +}; + +type LeadFollowupAuditPayload = { + id: string; + leadId: string; + followupDate: string; + followupStatus: string; + note: string | null; + nextFollowupDate: string | null; + createdBy: string; + createdAt: string; +}; + +function mapOption( + option: Awaited>[number] +): LeadOption { + return { + id: option.id, + code: option.code, + label: option.label, + value: option.value + }; +} + +function mapBranch( + branch: Awaited>[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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/features/crm/leads/types.ts b/src/features/crm/leads/types.ts new file mode 100644 index 0000000..7c9829b --- /dev/null +++ b/src/features/crm/leads/types.ts @@ -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; +}