task-d.5.3

This commit is contained in:
phaichayon
2026-06-24 14:23:15 +07:00
parent 350239b307
commit 0c28735c90
61 changed files with 11019 additions and 173 deletions

View File

@@ -4,10 +4,14 @@ export const leadSchema = z.object({
branchId: z.string().nullable().optional(),
customerId: z.string().nullable().optional(),
contactId: z.string().nullable().optional(),
description: z.string().nullable().optional(),
projectName: z.string().nullable().optional(),
projectLocation: z.string().nullable().optional(),
productType: z.string().nullable().optional(),
priority: z.string().nullable().optional(),
estimatedValue: z.number().nullable().optional(),
awarenessId: z.string().nullable().optional(),
status: z.string().min(1, 'Please select a lead status'),
status: z.string().min(1, 'Please select lead status'),
followupStatus: z.string().nullable().optional(),
lostReason: z.string().nullable().optional(),
outcome: z.enum(['open', 'won', 'lost']).default('open'),
@@ -16,9 +20,14 @@ export const leadSchema = z.object({
export const updateLeadSchema = leadSchema.partial();
export const assignLeadSchema = z.object({
salesOwnerId: z.string().min(1, 'Sales owner is required'),
assignmentRemark: z.string().nullable().optional()
});
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'),
followupDate: z.string().min(1, 'Follow-up date required'),
followupStatus: z.string().min(1, 'Please select follow-up status'),
note: z.string().nullable().optional(),
nextFollowupDate: z.string().nullable().optional()
});

View File

@@ -0,0 +1,300 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { crmEnquiries, crmLeads } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
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 {
canAccessScopedCrmRecord,
type CrmSecurityContext
} from '@/features/crm/security/server/service';
import {
assertAssignableUserBelongsToOrganization,
assertContactBelongsToOrganization,
assertCustomerBelongsToOrganization
} from '@/features/crm/enquiries/server/service';
import type { AssignLeadInput, AssignLeadResult } from '../types';
export type LeadAssignmentAccessContext = CrmSecurityContext;
type LeadRow = typeof crmLeads.$inferSelect;
type EnquiryRow = typeof crmEnquiries.$inferSelect;
function canAccessLeadRow(row: LeadRow, accessContext: LeadAssignmentAccessContext) {
return canAccessScopedCrmRecord(accessContext, {
branchId: row.branchId,
createdBy: row.createdBy,
ownerUserId: row.ownerMarketingUserId
});
}
async function assertLeadBelongsToOrganization(
leadId: string,
organizationId: string,
accessContext?: LeadAssignmentAccessContext
) {
const [lead] = await db
.select()
.from(crmLeads)
.where(
and(
eq(crmLeads.id, leadId),
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 resolveDefaultEnquiryStatusId(organizationId: string) {
const options = await getActiveOptionsByCategory('crm_enquiry_status', { organizationId });
const preferred = options.find((option) => option.code === 'new');
const resolved = preferred ?? options[0];
if (!resolved) {
throw new AuthError('Enquiry status options are not configured', 400);
}
return resolved.id;
}
async function findActiveEnquiryByLead(leadId: string, organizationId: string) {
const [enquiry] = await db
.select()
.from(crmEnquiries)
.where(
and(
eq(crmEnquiries.leadId, leadId),
eq(crmEnquiries.organizationId, organizationId),
inArray(crmEnquiries.pipelineStage, ['lead', 'enquiry']),
isNull(crmEnquiries.deletedAt)
)
)
.orderBy(asc(crmEnquiries.createdAt))
.limit(1);
return enquiry ?? null;
}
async function syncLeadAssignment(input: {
leadId: string;
status: string;
salesOwnerId: string;
assignedBy: string;
assignmentRemark?: string | null;
}) {
const now = new Date();
await db
.update(crmLeads)
.set({
status: input.status,
assignedSalesOwnerId: input.salesOwnerId,
assignedAt: now,
assignedBy: input.assignedBy,
assignmentRemark: input.assignmentRemark?.trim() || null,
updatedAt: now
})
.where(eq(crmLeads.id, input.leadId));
}
async function createEnquiryFromLead(input: {
lead: LeadRow;
organizationId: string;
actorUserId: string;
salesOwnerId: string;
assignmentRemark?: string | null;
}) {
if (!input.lead.customerId) {
throw new AuthError('Lead requires a customer before assignment', 400);
}
if (!input.lead.productType) {
throw new AuthError('Lead requires a product type before assignment', 400);
}
if (!input.lead.priority) {
throw new AuthError('Lead requires a priority before assignment', 400);
}
await assertCustomerBelongsToOrganization(input.lead.customerId, input.organizationId);
if (input.lead.contactId) {
await assertContactBelongsToOrganization(
input.lead.contactId,
input.organizationId,
input.lead.customerId
);
}
const [statusId, documentCode] = await Promise.all([
resolveDefaultEnquiryStatusId(input.organizationId),
generateNextDocumentCode({
organizationId: input.organizationId,
documentType: 'crm_enquiry',
branchId: input.lead.branchId ?? null
})
]);
const now = new Date();
const [created] = await db
.insert(crmEnquiries)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
branchId: input.lead.branchId ?? null,
leadId: input.lead.id,
code: documentCode.code,
customerId: input.lead.customerId,
contactId: input.lead.contactId ?? null,
title: input.lead.projectName?.trim() || `Lead ${input.lead.code}`,
description: input.lead.description?.trim() || null,
requirement: null,
projectName: input.lead.projectName?.trim() || null,
projectLocation: input.lead.projectLocation?.trim() || null,
productType: input.lead.productType,
status: statusId,
priority: input.lead.priority,
leadChannel: null,
estimatedValue: input.lead.estimatedValue ?? null,
chancePercent: null,
expectedCloseDate: null,
competitor: null,
source: `lead:${input.lead.code}`,
notes: null,
isHotProject: false,
isActive: true,
pipelineStage: 'enquiry',
assignedToUserId: input.salesOwnerId,
assignedAt: now,
assignedBy: input.actorUserId,
assignmentRemark: input.assignmentRemark?.trim() || null,
createdBy: input.actorUserId,
updatedBy: input.actorUserId
})
.returning();
return created;
}
export async function assignLead(
organizationId: string,
actorUserId: string,
input: AssignLeadInput,
accessContext?: LeadAssignmentAccessContext
): Promise<AssignLeadResult> {
const lead = await assertLeadBelongsToOrganization(input.leadId, organizationId, accessContext);
await assertAssignableUserBelongsToOrganization(input.salesOwnerId, organizationId);
const existingEnquiry = await findActiveEnquiryByLead(input.leadId, organizationId);
if (existingEnquiry) {
const resolvedSalesOwnerId = existingEnquiry.assignedToUserId ?? input.salesOwnerId;
if (!existingEnquiry.assignedToUserId) {
await db
.update(crmEnquiries)
.set({
pipelineStage: 'enquiry',
assignedToUserId: input.salesOwnerId,
assignedAt: new Date(),
assignedBy: actorUserId,
assignmentRemark: input.assignmentRemark?.trim() || null,
updatedAt: new Date(),
updatedBy: actorUserId
})
.where(eq(crmEnquiries.id, existingEnquiry.id));
}
await syncLeadAssignment({
leadId: input.leadId,
status: 'assigned',
salesOwnerId: resolvedSalesOwnerId,
assignedBy: actorUserId,
assignmentRemark: input.assignmentRemark
});
await auditAction({
organizationId,
branchId: lead.branchId,
userId: actorUserId,
entityType: 'crm_lead',
entityId: input.leadId,
action: 'assign_lead',
afterData: {
leadId: input.leadId,
enquiryId: existingEnquiry.id,
salesOwnerId: resolvedSalesOwnerId,
reusedExistingEnquiry: true
}
});
return {
leadId: input.leadId,
enquiryId: existingEnquiry.id,
enquiryCode: existingEnquiry.code
};
}
const createdEnquiry = await createEnquiryFromLead({
lead,
organizationId,
actorUserId,
salesOwnerId: input.salesOwnerId,
assignmentRemark: input.assignmentRemark
});
await syncLeadAssignment({
leadId: input.leadId,
status: 'assigned',
salesOwnerId: input.salesOwnerId,
assignedBy: actorUserId,
assignmentRemark: input.assignmentRemark
});
await auditAction({
organizationId,
branchId: lead.branchId,
userId: actorUserId,
entityType: 'crm_enquiry',
entityId: createdEnquiry.id,
action: 'create_enquiry_from_lead',
afterData: {
leadId: input.leadId,
enquiryId: createdEnquiry.id,
salesOwnerId: input.salesOwnerId
}
});
await auditAction({
organizationId,
branchId: lead.branchId,
userId: actorUserId,
entityType: 'crm_lead',
entityId: input.leadId,
action: 'assign_lead',
afterData: {
leadId: input.leadId,
enquiryId: createdEnquiry.id,
salesOwnerId: input.salesOwnerId,
reusedExistingEnquiry: false
}
});
return {
leadId: input.leadId,
enquiryId: createdEnquiry.id,
enquiryCode: createdEnquiry.code
};
}

View File

@@ -49,7 +49,9 @@ const LEAD_OPTION_CATEGORIES = {
awareness: 'crm_lead_awareness',
status: 'crm_lead_status',
followupStatus: 'crm_lead_followup_status',
lostReason: 'crm_lead_lost_reason'
lostReason: 'crm_lead_lost_reason',
productType: 'crm_product_type',
priority: 'crm_priority'
} as const;
export type LeadAccessContext = CrmSecurityContext;
@@ -61,6 +63,8 @@ type LeadLookupMaps = {
statusLabels: Map<string, string>;
followupStatusLabels: Map<string, string>;
lostReasonLabels: Map<string, string>;
productTypeLabels: Map<string, string>;
priorityLabels: Map<string, string>;
customerNames: Map<string, string>;
contactNames: Map<string, string>;
userNames: Map<string, string>;
@@ -121,8 +125,12 @@ function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
customerName: row.customerId ? (lookups.customerNames.get(row.customerId) ?? null) : null,
contactId: row.contactId ?? null,
contactName: row.contactId ? (lookups.contactNames.get(row.contactId) ?? null) : null,
description: row.description ?? null,
projectName: row.projectName ?? null,
projectLocation: row.projectLocation ?? null,
productType: row.productType ?? null,
priority: row.priority ?? null,
estimatedValue: row.estimatedValue ?? null,
awarenessId: row.awarenessId ?? null,
awarenessLabel: row.awarenessId ? (lookups.awarenessLabels.get(row.awarenessId) ?? null) : null,
status: row.status,
@@ -138,6 +146,14 @@ function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
ownerMarketingName: row.ownerMarketingUserId
? (lookups.userNames.get(row.ownerMarketingUserId) ?? null)
: null,
assignedSalesOwnerId: row.assignedSalesOwnerId ?? null,
assignedSalesOwnerName: row.assignedSalesOwnerId
? (lookups.userNames.get(row.assignedSalesOwnerId) ?? null)
: null,
assignedAt: toIsoDateTime(row.assignedAt),
assignedBy: row.assignedBy ?? null,
assignedByName: row.assignedBy ? (lookups.userNames.get(row.assignedBy) ?? null) : null,
assignmentRemark: row.assignmentRemark ?? null,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -339,12 +355,15 @@ async function assertLeadBelongsToOrganization(
}
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 [awarenesses, statuses, followupStatuses, lostReasons, productTypes, priorities] =
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 }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.productType, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.priority, { organizationId })
]);
const customerIds = [
...new Set(rows.map((row) => row.customerId).filter((value): value is string => Boolean(value)))
];
@@ -354,7 +373,12 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
const userIds = [
...new Set(
rows
.flatMap((row) => [row.ownerMarketingUserId, row.createdBy])
.flatMap((row) => [
row.ownerMarketingUserId,
row.assignedSalesOwnerId,
row.assignedBy,
row.createdBy
])
.filter((value): value is string => Boolean(value))
)
];
@@ -385,6 +409,8 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
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])),
productTypeLabels: new Map(productTypes.map((item) => [item.id, item.label])),
priorityLabels: new Map(priorities.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]))
@@ -399,6 +425,12 @@ async function validateLeadPayload(
) {
await Promise.all([
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.awareness, payload.awarenessId),
assertMasterOptionValue(
organizationId,
LEAD_OPTION_CATEGORIES.productType,
payload.productType
),
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.priority, payload.priority),
payload.status
? assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.status, payload.status)
: Promise.resolve(),
@@ -454,19 +486,24 @@ async function validateLeadPayload(
}
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()
]);
const [awarenesses, statuses, followupStatuses, lostReasons, productTypes, priorities, 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 }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.productType, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.priority, { organizationId }),
getUserBranches()
]);
return {
awarenesses: awarenesses.map(mapOption),
statuses: statuses.map(mapOption),
followupStatuses: followupStatuses.map(mapOption),
lostReasons: lostReasons.map(mapOption),
productTypes: productTypes.map(mapOption),
priorities: priorities.map(mapOption),
branches: branches.map(mapBranch)
};
}
@@ -570,8 +607,12 @@ export async function createLead(
code: codeResult.code,
customerId: payload.customerId ?? null,
contactId: payload.contactId ?? null,
description: payload.description?.trim() || null,
projectName: payload.projectName?.trim() || null,
projectLocation: payload.projectLocation?.trim() || null,
productType: payload.productType ?? null,
priority: payload.priority ?? null,
estimatedValue: payload.estimatedValue ?? null,
awarenessId: payload.awarenessId ?? null,
status: payload.status,
followupStatus: payload.followupStatus ?? null,
@@ -600,9 +641,14 @@ export async function updateLead(
payload.contactId === undefined
? current.contactId
: payload.contactId,
description: payload.description === undefined ? current.description : payload.description,
projectName: payload.projectName === undefined ? current.projectName : payload.projectName,
projectLocation:
payload.projectLocation === undefined ? current.projectLocation : payload.projectLocation,
productType: payload.productType === undefined ? current.productType : payload.productType,
priority: payload.priority === undefined ? current.priority : payload.priority,
estimatedValue:
payload.estimatedValue === undefined ? current.estimatedValue : payload.estimatedValue,
awarenessId: payload.awarenessId === undefined ? current.awarenessId : payload.awarenessId,
status: payload.status ?? current.status,
followupStatus:
@@ -623,8 +669,12 @@ export async function updateLead(
branchId: nextPayload.branchId ?? null,
customerId: nextPayload.customerId ?? null,
contactId: nextPayload.contactId ?? null,
description: nextPayload.description?.trim() || null,
projectName: nextPayload.projectName?.trim() || null,
projectLocation: nextPayload.projectLocation?.trim() || null,
productType: nextPayload.productType ?? null,
priority: nextPayload.priority ?? null,
estimatedValue: nextPayload.estimatedValue ?? null,
awarenessId: nextPayload.awarenessId ?? null,
status: nextPayload.status,
followupStatus: nextPayload.followupStatus ?? null,

View File

@@ -17,6 +17,8 @@ export interface LeadReferenceData {
statuses: LeadOption[];
followupStatuses: LeadOption[];
lostReasons: LeadOption[];
productTypes: LeadOption[];
priorities: LeadOption[];
branches: LeadBranchOption[];
}
@@ -24,8 +26,12 @@ export interface CreateLeadInput {
branchId?: string | null;
customerId?: string | null;
contactId?: string | null;
description?: string | null;
projectName?: string | null;
projectLocation?: string | null;
productType?: string | null;
priority?: string | null;
estimatedValue?: number | null;
awarenessId?: string | null;
status: string;
followupStatus?: string | null;
@@ -38,8 +44,12 @@ export interface UpdateLeadInput {
branchId?: string | null;
customerId?: string | null;
contactId?: string | null;
description?: string | null;
projectName?: string | null;
projectLocation?: string | null;
productType?: string | null;
priority?: string | null;
estimatedValue?: number | null;
awarenessId?: string | null;
status?: string;
followupStatus?: string | null;
@@ -69,6 +79,12 @@ export interface CreateLeadFollowupInput {
nextFollowupDate?: string | null;
}
export interface AssignLeadInput {
leadId: string;
salesOwnerId: string;
assignmentRemark?: string | null;
}
export interface LeadSummary {
id: string;
organizationId: string;
@@ -78,8 +94,12 @@ export interface LeadSummary {
customerName: string | null;
contactId: string | null;
contactName: string | null;
description: string | null;
projectName: string | null;
projectLocation: string | null;
productType: string | null;
priority: string | null;
estimatedValue: number | null;
awarenessId: string | null;
awarenessLabel: string | null;
status: string;
@@ -91,6 +111,12 @@ export interface LeadSummary {
outcome: string;
ownerMarketingUserId: string | null;
ownerMarketingName: string | null;
assignedSalesOwnerId: string | null;
assignedSalesOwnerName: string | null;
assignedAt: string | null;
assignedBy: string | null;
assignedByName: string | null;
assignmentRemark: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
@@ -119,3 +145,9 @@ export interface LeadFollowupSummary {
createdBy: string;
createdByName: string | null;
}
export interface AssignLeadResult {
leadId: string;
enquiryId: string;
enquiryCode: string;
}