task-d.5.2.1

This commit is contained in:
phaichayon
2026-06-24 13:39:25 +07:00
parent b42a4c0777
commit 350239b307
9 changed files with 1943 additions and 1 deletions

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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()
});

View 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);
}

View 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;
}