task-d.5.2.1
This commit is contained in:
94
src/app/api/crm/leads/[id]/followups/route.ts
Normal file
94
src/app/api/crm/leads/[id]/followups/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { leadFollowupSchema } from '@/features/crm/leads/schemas/lead.schema';
|
||||
import {
|
||||
buildLeadAccessContext,
|
||||
createLeadFollowup,
|
||||
listLeadFollowups
|
||||
} from '@/features/crm/leads/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmLeadRead
|
||||
});
|
||||
const accessContext = buildLeadAccessContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const items = await listLeadFollowups(id, organization.id, accessContext);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Lead follow-ups loaded successfully',
|
||||
items
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load lead follow-ups' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmLeadUpdate
|
||||
});
|
||||
const payload = leadFollowupSchema.parse(await request.json());
|
||||
const accessContext = buildLeadAccessContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const created = await createLeadFollowup(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Lead follow-up created successfully',
|
||||
followup: created
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to create lead follow-up' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
167
src/app/api/crm/leads/[id]/route.ts
Normal file
167
src/app/api/crm/leads/[id]/route.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { updateLeadSchema } from '@/features/crm/leads/schemas/lead.schema';
|
||||
import {
|
||||
buildLeadAccessContext,
|
||||
deleteLead,
|
||||
getLeadById,
|
||||
getLeadReferenceData,
|
||||
listLeadFollowups,
|
||||
updateLead
|
||||
} from '@/features/crm/leads/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmLeadRead
|
||||
});
|
||||
const accessContext = buildLeadAccessContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const [lead, followups, referenceData] = await Promise.all([
|
||||
getLeadById(id, organization.id, accessContext),
|
||||
listLeadFollowups(id, organization.id, accessContext),
|
||||
getLeadReferenceData(organization.id)
|
||||
]);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
branchId: lead.branchId,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_lead',
|
||||
entityId: id,
|
||||
action: 'view_lead',
|
||||
afterData: {
|
||||
code: lead.code
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Lead loaded successfully',
|
||||
lead,
|
||||
followups,
|
||||
referenceData
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load lead' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmLeadUpdate
|
||||
});
|
||||
const payload = updateLeadSchema.parse(await request.json());
|
||||
const accessContext = buildLeadAccessContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = await getLeadById(id, organization.id, accessContext);
|
||||
const updated = await updateLead(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
branchId: updated.branchId,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_lead',
|
||||
entityId: id,
|
||||
action: 'update_lead',
|
||||
beforeData: before,
|
||||
afterData: updated
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Lead updated successfully',
|
||||
lead: updated
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to update lead' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmLeadDelete
|
||||
});
|
||||
const accessContext = buildLeadAccessContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = await getLeadById(id, organization.id, accessContext);
|
||||
const deleted = await deleteLead(id, organization.id, accessContext);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
branchId: deleted.branchId,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_lead',
|
||||
entityId: id,
|
||||
action: 'delete_lead',
|
||||
beforeData: before,
|
||||
afterData: deleted
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Lead deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to delete lead' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
125
src/app/api/crm/leads/route.ts
Normal file
125
src/app/api/crm/leads/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { leadSchema } from '@/features/crm/leads/schemas/lead.schema';
|
||||
import {
|
||||
buildLeadAccessContext,
|
||||
createLead,
|
||||
getLeadReferenceData,
|
||||
listLeads
|
||||
} from '@/features/crm/leads/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmLeadRead
|
||||
});
|
||||
const { searchParams } = request.nextUrl;
|
||||
const page = Number(searchParams.get('page') ?? 1);
|
||||
const limit = Number(searchParams.get('limit') ?? 10);
|
||||
const accessContext = buildLeadAccessContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const [result, referenceData] = await Promise.all([
|
||||
listLeads(
|
||||
organization.id,
|
||||
{
|
||||
page,
|
||||
limit,
|
||||
search: searchParams.get('search') ?? undefined,
|
||||
branchId: searchParams.get('branchId') ?? undefined,
|
||||
customerId: searchParams.get('customerId') ?? undefined,
|
||||
status: searchParams.get('status') ?? undefined,
|
||||
awarenessId: searchParams.get('awarenessId') ?? undefined,
|
||||
followupStatus: searchParams.get('followupStatus') ?? undefined,
|
||||
outcome: searchParams.get('outcome') ?? undefined,
|
||||
ownerMarketingUserId: searchParams.get('ownerMarketingUserId') ?? undefined,
|
||||
sort: searchParams.get('sort') ?? undefined
|
||||
},
|
||||
accessContext
|
||||
),
|
||||
getLeadReferenceData(organization.id)
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Leads loaded successfully',
|
||||
totalItems: result.totalItems,
|
||||
offset: (page - 1) * limit,
|
||||
limit,
|
||||
items: result.items,
|
||||
referenceData
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load leads' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmLeadCreate
|
||||
});
|
||||
const payload = leadSchema.parse(await request.json());
|
||||
const accessContext = buildLeadAccessContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const created = await createLead(
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
branchId: created.branchId,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_lead',
|
||||
entityId: created.id,
|
||||
action: 'create_lead',
|
||||
afterData: created
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Lead created successfully',
|
||||
lead: created
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to create lead' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
24
src/features/crm/leads/schemas/lead.schema.ts
Normal file
24
src/features/crm/leads/schemas/lead.schema.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const leadSchema = z.object({
|
||||
branchId: z.string().nullable().optional(),
|
||||
customerId: z.string().nullable().optional(),
|
||||
contactId: z.string().nullable().optional(),
|
||||
projectName: z.string().nullable().optional(),
|
||||
projectLocation: z.string().nullable().optional(),
|
||||
awarenessId: z.string().nullable().optional(),
|
||||
status: z.string().min(1, 'Please select a lead status'),
|
||||
followupStatus: z.string().nullable().optional(),
|
||||
lostReason: z.string().nullable().optional(),
|
||||
outcome: z.enum(['open', 'won', 'lost']).default('open'),
|
||||
ownerMarketingUserId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const updateLeadSchema = leadSchema.partial();
|
||||
|
||||
export const leadFollowupSchema = z.object({
|
||||
followupDate: z.string().min(1, 'Follow-up date is required'),
|
||||
followupStatus: z.string().min(1, 'Please select a follow-up status'),
|
||||
note: z.string().nullable().optional(),
|
||||
nextFollowupDate: z.string().nullable().optional()
|
||||
});
|
||||
814
src/features/crm/leads/server/service.ts
Normal file
814
src/features/crm/leads/server/service.ts
Normal file
@@ -0,0 +1,814 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
or,
|
||||
type SQL
|
||||
} from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomerContacts,
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmLeads,
|
||||
memberships,
|
||||
trAuditLogs,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import {
|
||||
buildCrmSecurityContext,
|
||||
canAccessCustomer,
|
||||
canAccessScopedCrmRecord,
|
||||
type CrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import type {
|
||||
CreateLeadFollowupInput,
|
||||
CreateLeadInput,
|
||||
LeadBranchOption,
|
||||
LeadDetail,
|
||||
LeadFollowupSummary,
|
||||
LeadListFilters,
|
||||
LeadListResponse,
|
||||
LeadOption,
|
||||
LeadReferenceData,
|
||||
LeadSummary,
|
||||
UpdateLeadInput
|
||||
} from '../types';
|
||||
|
||||
const LEAD_OPTION_CATEGORIES = {
|
||||
awareness: 'crm_lead_awareness',
|
||||
status: 'crm_lead_status',
|
||||
followupStatus: 'crm_lead_followup_status',
|
||||
lostReason: 'crm_lead_lost_reason'
|
||||
} as const;
|
||||
|
||||
export type LeadAccessContext = CrmSecurityContext;
|
||||
|
||||
type LeadRow = typeof crmLeads.$inferSelect;
|
||||
|
||||
type LeadLookupMaps = {
|
||||
awarenessLabels: Map<string, string>;
|
||||
statusLabels: Map<string, string>;
|
||||
followupStatusLabels: Map<string, string>;
|
||||
lostReasonLabels: Map<string, string>;
|
||||
customerNames: Map<string, string>;
|
||||
contactNames: Map<string, string>;
|
||||
userNames: Map<string, string>;
|
||||
};
|
||||
|
||||
type LeadFollowupAuditPayload = {
|
||||
id: string;
|
||||
leadId: string;
|
||||
followupDate: string;
|
||||
followupStatus: string;
|
||||
note: string | null;
|
||||
nextFollowupDate: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function mapOption(
|
||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||
): LeadOption {
|
||||
return {
|
||||
id: option.id,
|
||||
code: option.code,
|
||||
label: option.label,
|
||||
value: option.value
|
||||
};
|
||||
}
|
||||
|
||||
function mapBranch(
|
||||
branch: Awaited<ReturnType<typeof getUserBranches>>[number]
|
||||
): LeadBranchOption {
|
||||
return {
|
||||
id: branch.id,
|
||||
code: branch.code,
|
||||
name: branch.name,
|
||||
value: branch.value
|
||||
};
|
||||
}
|
||||
|
||||
function toIsoDateTime(value: Date | null | undefined) {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function normalizeLeadOutcome(value?: string | null): 'open' | 'won' | 'lost' {
|
||||
if (value === 'won' || value === 'lost') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
branchId: row.branchId ?? null,
|
||||
code: row.code,
|
||||
customerId: row.customerId ?? null,
|
||||
customerName: row.customerId ? (lookups.customerNames.get(row.customerId) ?? null) : null,
|
||||
contactId: row.contactId ?? null,
|
||||
contactName: row.contactId ? (lookups.contactNames.get(row.contactId) ?? null) : null,
|
||||
projectName: row.projectName ?? null,
|
||||
projectLocation: row.projectLocation ?? null,
|
||||
awarenessId: row.awarenessId ?? null,
|
||||
awarenessLabel: row.awarenessId ? (lookups.awarenessLabels.get(row.awarenessId) ?? null) : null,
|
||||
status: row.status,
|
||||
statusLabel: lookups.statusLabels.get(row.status) ?? null,
|
||||
followupStatus: row.followupStatus ?? null,
|
||||
followupStatusLabel: row.followupStatus
|
||||
? (lookups.followupStatusLabels.get(row.followupStatus) ?? null)
|
||||
: null,
|
||||
lostReason: row.lostReason ?? null,
|
||||
lostReasonLabel: row.lostReason ? (lookups.lostReasonLabels.get(row.lostReason) ?? null) : null,
|
||||
outcome: row.outcome,
|
||||
ownerMarketingUserId: row.ownerMarketingUserId ?? null,
|
||||
ownerMarketingName: row.ownerMarketingUserId
|
||||
? (lookups.userNames.get(row.ownerMarketingUserId) ?? null)
|
||||
: null,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: toIsoDateTime(row.deletedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function mapLeadDetail(
|
||||
row: LeadRow,
|
||||
lookups: LeadLookupMaps,
|
||||
suggestedSalesOwnerId: string | null,
|
||||
relatedEnquiryCount: number
|
||||
): LeadDetail {
|
||||
return {
|
||||
...mapLeadSummary(row, lookups),
|
||||
suggestedSalesOwnerId,
|
||||
relatedEnquiryCount
|
||||
};
|
||||
}
|
||||
|
||||
function parseSort(sort?: string) {
|
||||
if (!sort) {
|
||||
return desc(crmLeads.updatedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
||||
const [rule] = parsed;
|
||||
|
||||
if (!rule) {
|
||||
return desc(crmLeads.updatedAt);
|
||||
}
|
||||
|
||||
const sortMap = {
|
||||
code: crmLeads.code,
|
||||
projectName: crmLeads.projectName,
|
||||
status: crmLeads.status,
|
||||
followupStatus: crmLeads.followupStatus,
|
||||
outcome: crmLeads.outcome,
|
||||
updatedAt: crmLeads.updatedAt,
|
||||
createdAt: crmLeads.createdAt
|
||||
} as const;
|
||||
|
||||
const column = sortMap[rule.id as keyof typeof sortMap];
|
||||
|
||||
if (!column) {
|
||||
return desc(crmLeads.updatedAt);
|
||||
}
|
||||
|
||||
return rule.desc ? desc(column) : asc(column);
|
||||
} catch {
|
||||
return desc(crmLeads.updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveValidOptionIds(category: string, organizationId: string) {
|
||||
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||
return new Set(options.map((option) => option.id));
|
||||
}
|
||||
|
||||
async function assertMasterOptionValue(
|
||||
organizationId: string,
|
||||
category: string,
|
||||
optionId?: string | null
|
||||
) {
|
||||
if (!optionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validIds = await resolveValidOptionIds(category, organizationId);
|
||||
|
||||
if (!validIds.has(optionId)) {
|
||||
throw new AuthError(`Invalid option for ${category}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
||||
const [customer] = await db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomers.id, id),
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
isNull(crmCustomers.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!customer) {
|
||||
throw new AuthError('Customer not found', 404);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
async function assertContactBelongsToOrganization(
|
||||
contactId: string,
|
||||
organizationId: string,
|
||||
customerId?: string | null
|
||||
) {
|
||||
const [contact] = await db
|
||||
.select()
|
||||
.from(crmCustomerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.id, contactId),
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt),
|
||||
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!contact) {
|
||||
throw new AuthError('Contact not found', 404);
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async function assertMembershipUserBelongsToOrganization(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
) {
|
||||
const [membership] = await db
|
||||
.select({ userId: memberships.userId })
|
||||
.from(memberships)
|
||||
.where(
|
||||
and(
|
||||
eq(memberships.userId, userId),
|
||||
eq(memberships.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
throw new AuthError('Assigned user not found in organization', 404);
|
||||
}
|
||||
}
|
||||
|
||||
function canAccessLeadRow(row: LeadRow, accessContext: LeadAccessContext) {
|
||||
return canAccessScopedCrmRecord(accessContext, {
|
||||
branchId: row.branchId,
|
||||
createdBy: row.createdBy,
|
||||
ownerUserId: row.ownerMarketingUserId
|
||||
});
|
||||
}
|
||||
|
||||
function buildAccessScopedFilters(context: LeadAccessContext): SQL[] {
|
||||
const filters: SQL[] = [];
|
||||
|
||||
if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) {
|
||||
filters.push(inArray(crmLeads.branchId, context.branchScopeIds));
|
||||
}
|
||||
|
||||
if (context.branchScopeMode === 'none') {
|
||||
filters.push(eq(crmLeads.id, '__forbidden__'));
|
||||
}
|
||||
|
||||
if (context.membershipRole !== 'admin' && context.ownershipScope === 'own') {
|
||||
filters.push(
|
||||
or(
|
||||
eq(crmLeads.createdBy, context.userId),
|
||||
eq(crmLeads.ownerMarketingUserId, context.userId)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
async function assertLeadBelongsToOrganization(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: LeadAccessContext
|
||||
) {
|
||||
const [lead] = await db
|
||||
.select()
|
||||
.from(crmLeads)
|
||||
.where(
|
||||
and(
|
||||
eq(crmLeads.id, id),
|
||||
eq(crmLeads.organizationId, organizationId),
|
||||
isNull(crmLeads.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!lead) {
|
||||
throw new AuthError('Lead not found', 404);
|
||||
}
|
||||
|
||||
if (accessContext && !canAccessLeadRow(lead, accessContext)) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
return lead;
|
||||
}
|
||||
|
||||
async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise<LeadLookupMaps> {
|
||||
const [awarenesses, statuses, followupStatuses, lostReasons] = await Promise.all([
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId })
|
||||
]);
|
||||
const customerIds = [
|
||||
...new Set(rows.map((row) => row.customerId).filter((value): value is string => Boolean(value)))
|
||||
];
|
||||
const contactIds = [
|
||||
...new Set(rows.map((row) => row.contactId).filter((value): value is string => Boolean(value)))
|
||||
];
|
||||
const userIds = [
|
||||
...new Set(
|
||||
rows
|
||||
.flatMap((row) => [row.ownerMarketingUserId, row.createdBy])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
];
|
||||
|
||||
const [customerRows, contactRows, userRows] = await Promise.all([
|
||||
customerIds.length
|
||||
? db
|
||||
.select({ id: crmCustomers.id, name: crmCustomers.name })
|
||||
.from(crmCustomers)
|
||||
.where(inArray(crmCustomers.id, customerIds))
|
||||
: Promise.resolve([]),
|
||||
contactIds.length
|
||||
? db
|
||||
.select({ id: crmCustomerContacts.id, name: crmCustomerContacts.name })
|
||||
.from(crmCustomerContacts)
|
||||
.where(inArray(crmCustomerContacts.id, contactIds))
|
||||
: Promise.resolve([]),
|
||||
userIds.length
|
||||
? db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, userIds))
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
return {
|
||||
awarenessLabels: new Map(awarenesses.map((item) => [item.id, item.label])),
|
||||
statusLabels: new Map(statuses.map((item) => [item.id, item.label])),
|
||||
followupStatusLabels: new Map(followupStatuses.map((item) => [item.id, item.label])),
|
||||
lostReasonLabels: new Map(lostReasons.map((item) => [item.id, item.label])),
|
||||
customerNames: new Map(customerRows.map((item) => [item.id, item.name])),
|
||||
contactNames: new Map(contactRows.map((item) => [item.id, item.name])),
|
||||
userNames: new Map(userRows.map((item) => [item.id, item.name]))
|
||||
};
|
||||
}
|
||||
|
||||
async function validateLeadPayload(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CreateLeadInput | UpdateLeadInput,
|
||||
accessContext?: LeadAccessContext
|
||||
) {
|
||||
await Promise.all([
|
||||
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.awareness, payload.awarenessId),
|
||||
payload.status
|
||||
? assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.status, payload.status)
|
||||
: Promise.resolve(),
|
||||
assertMasterOptionValue(
|
||||
organizationId,
|
||||
LEAD_OPTION_CATEGORIES.followupStatus,
|
||||
payload.followupStatus
|
||||
),
|
||||
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.lostReason, payload.lostReason)
|
||||
]);
|
||||
|
||||
if (payload.branchId) {
|
||||
await validateBranchAccess(payload.branchId);
|
||||
}
|
||||
|
||||
if (payload.customerId) {
|
||||
const customer = await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
||||
|
||||
if (accessContext && !canAccessCustomer(accessContext, customer)) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.contactId) {
|
||||
await assertContactBelongsToOrganization(
|
||||
payload.contactId,
|
||||
organizationId,
|
||||
payload.customerId ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.ownerMarketingUserId) {
|
||||
await assertMembershipUserBelongsToOrganization(payload.ownerMarketingUserId, organizationId);
|
||||
}
|
||||
|
||||
if (accessContext) {
|
||||
const draftRow = {
|
||||
branchId: payload.branchId ?? null,
|
||||
createdBy: userId,
|
||||
ownerMarketingUserId: payload.ownerMarketingUserId ?? userId
|
||||
};
|
||||
|
||||
if (
|
||||
!canAccessScopedCrmRecord(accessContext, {
|
||||
branchId: draftRow.branchId,
|
||||
createdBy: draftRow.createdBy,
|
||||
ownerUserId: draftRow.ownerMarketingUserId
|
||||
})
|
||||
) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLeadReferenceData(organizationId: string): Promise<LeadReferenceData> {
|
||||
const [awarenesses, statuses, followupStatuses, lostReasons, branches] = await Promise.all([
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||
getUserBranches()
|
||||
]);
|
||||
|
||||
return {
|
||||
awarenesses: awarenesses.map(mapOption),
|
||||
statuses: statuses.map(mapOption),
|
||||
followupStatuses: followupStatuses.map(mapOption),
|
||||
lostReasons: lostReasons.map(mapOption),
|
||||
branches: branches.map(mapBranch)
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLeads(
|
||||
organizationId: string,
|
||||
filters: LeadListFilters = {},
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadListResponse> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const offset = (page - 1) * limit;
|
||||
const whereFilters: SQL[] = [
|
||||
eq(crmLeads.organizationId, organizationId),
|
||||
isNull(crmLeads.deletedAt),
|
||||
...(filters.branchId ? [eq(crmLeads.branchId, filters.branchId)] : []),
|
||||
...(filters.customerId ? [eq(crmLeads.customerId, filters.customerId)] : []),
|
||||
...(filters.status ? [eq(crmLeads.status, filters.status)] : []),
|
||||
...(filters.awarenessId ? [eq(crmLeads.awarenessId, filters.awarenessId)] : []),
|
||||
...(filters.followupStatus ? [eq(crmLeads.followupStatus, filters.followupStatus)] : []),
|
||||
...(filters.outcome ? [eq(crmLeads.outcome, filters.outcome)] : []),
|
||||
...(filters.ownerMarketingUserId
|
||||
? [eq(crmLeads.ownerMarketingUserId, filters.ownerMarketingUserId)]
|
||||
: []),
|
||||
...(filters.search
|
||||
? [
|
||||
or(
|
||||
ilike(crmLeads.code, `%${filters.search}%`),
|
||||
ilike(crmLeads.projectName, `%${filters.search}%`),
|
||||
ilike(crmLeads.projectLocation, `%${filters.search}%`)
|
||||
)!
|
||||
]
|
||||
: []),
|
||||
...(accessContext ? buildAccessScopedFilters(accessContext) : [])
|
||||
];
|
||||
const where = and(...whereFilters);
|
||||
const [totalResult] = await db.select({ value: count() }).from(crmLeads).where(where);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmLeads)
|
||||
.where(where)
|
||||
.orderBy(parseSort(filters.sort))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const lookups = await buildLookupMaps(organizationId, rows);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => mapLeadSummary(row, lookups)),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLeadById(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadDetail> {
|
||||
const lead = await assertLeadBelongsToOrganization(id, organizationId, accessContext);
|
||||
const [lookups, relatedEnquiryResult, customer] = await Promise.all([
|
||||
buildLookupMaps(organizationId, [lead]),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(crmEnquiries)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
eq(crmEnquiries.leadId, id),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
),
|
||||
lead.customerId ? assertCustomerBelongsToOrganization(lead.customerId, organizationId) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
return mapLeadDetail(
|
||||
lead,
|
||||
lookups,
|
||||
customer?.ownerUserId ?? null,
|
||||
relatedEnquiryResult[0]?.value ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLead(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CreateLeadInput,
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadDetail> {
|
||||
await validateLeadPayload(organizationId, userId, payload, accessContext);
|
||||
const codeResult = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
documentType: 'crm_lead',
|
||||
branchId: payload.branchId ?? null
|
||||
});
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmLeads)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
branchId: payload.branchId ?? null,
|
||||
code: codeResult.code,
|
||||
customerId: payload.customerId ?? null,
|
||||
contactId: payload.contactId ?? null,
|
||||
projectName: payload.projectName?.trim() || null,
|
||||
projectLocation: payload.projectLocation?.trim() || null,
|
||||
awarenessId: payload.awarenessId ?? null,
|
||||
status: payload.status,
|
||||
followupStatus: payload.followupStatus ?? null,
|
||||
lostReason: payload.lostReason ?? null,
|
||||
outcome: normalizeLeadOutcome(payload.outcome),
|
||||
ownerMarketingUserId: payload.ownerMarketingUserId ?? userId,
|
||||
createdBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
return getLeadById(created.id, organizationId, accessContext);
|
||||
}
|
||||
|
||||
export async function updateLead(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: UpdateLeadInput,
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadDetail> {
|
||||
const current = await assertLeadBelongsToOrganization(id, organizationId, accessContext);
|
||||
const nextPayload = {
|
||||
branchId: payload.branchId === undefined ? current.branchId : payload.branchId,
|
||||
customerId: payload.customerId === undefined ? current.customerId : payload.customerId,
|
||||
contactId:
|
||||
payload.contactId === undefined
|
||||
? current.contactId
|
||||
: payload.contactId,
|
||||
projectName: payload.projectName === undefined ? current.projectName : payload.projectName,
|
||||
projectLocation:
|
||||
payload.projectLocation === undefined ? current.projectLocation : payload.projectLocation,
|
||||
awarenessId: payload.awarenessId === undefined ? current.awarenessId : payload.awarenessId,
|
||||
status: payload.status ?? current.status,
|
||||
followupStatus:
|
||||
payload.followupStatus === undefined ? current.followupStatus : payload.followupStatus,
|
||||
lostReason: payload.lostReason === undefined ? current.lostReason : payload.lostReason,
|
||||
outcome: payload.outcome ?? normalizeLeadOutcome(current.outcome),
|
||||
ownerMarketingUserId:
|
||||
payload.ownerMarketingUserId === undefined
|
||||
? current.ownerMarketingUserId
|
||||
: payload.ownerMarketingUserId
|
||||
} satisfies CreateLeadInput;
|
||||
|
||||
await validateLeadPayload(organizationId, userId, nextPayload, accessContext);
|
||||
|
||||
await db
|
||||
.update(crmLeads)
|
||||
.set({
|
||||
branchId: nextPayload.branchId ?? null,
|
||||
customerId: nextPayload.customerId ?? null,
|
||||
contactId: nextPayload.contactId ?? null,
|
||||
projectName: nextPayload.projectName?.trim() || null,
|
||||
projectLocation: nextPayload.projectLocation?.trim() || null,
|
||||
awarenessId: nextPayload.awarenessId ?? null,
|
||||
status: nextPayload.status,
|
||||
followupStatus: nextPayload.followupStatus ?? null,
|
||||
lostReason: nextPayload.lostReason ?? null,
|
||||
outcome: normalizeLeadOutcome(nextPayload.outcome),
|
||||
ownerMarketingUserId: nextPayload.ownerMarketingUserId ?? null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmLeads.id, id));
|
||||
|
||||
return getLeadById(id, organizationId, accessContext);
|
||||
}
|
||||
|
||||
export async function deleteLead(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadDetail> {
|
||||
await assertLeadBelongsToOrganization(id, organizationId, accessContext);
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.update(crmLeads)
|
||||
.set({
|
||||
deletedAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(crmLeads.id, id));
|
||||
|
||||
return getLeadById(id, organizationId, accessContext).catch(async () => {
|
||||
const [deleted] = await db
|
||||
.select()
|
||||
.from(crmLeads)
|
||||
.where(eq(crmLeads.id, id))
|
||||
.limit(1);
|
||||
|
||||
const lookups = await buildLookupMaps(organizationId, deleted ? [deleted] : []);
|
||||
|
||||
return mapLeadDetail(deleted!, lookups, null, 0);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listLeadFollowups(
|
||||
leadId: string,
|
||||
organizationId: string,
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadFollowupSummary[]> {
|
||||
await assertLeadBelongsToOrganization(leadId, organizationId, accessContext);
|
||||
const [followupStatuses, rows] = await Promise.all([
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||
db
|
||||
.select()
|
||||
.from(trAuditLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(trAuditLogs.organizationId, organizationId),
|
||||
eq(trAuditLogs.entityType, 'crm_lead'),
|
||||
eq(trAuditLogs.entityId, leadId),
|
||||
eq(trAuditLogs.action, 'create_lead_followup')
|
||||
)
|
||||
)
|
||||
.orderBy(desc(trAuditLogs.createdAt))
|
||||
]);
|
||||
const userIds = [
|
||||
...new Set(rows.map((row) => row.userId).filter((value): value is string => Boolean(value)))
|
||||
];
|
||||
const userRows = userIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, userIds))
|
||||
: [];
|
||||
const userNames = new Map(userRows.map((row) => [row.id, row.name]));
|
||||
const followupStatusLabels = new Map(followupStatuses.map((item) => [item.id, item.label]));
|
||||
|
||||
return rows
|
||||
.map((row) => {
|
||||
const payload = row.afterData as LeadFollowupAuditPayload | null;
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.id,
|
||||
leadId: payload.leadId,
|
||||
followupDate: payload.followupDate,
|
||||
followupStatus: payload.followupStatus,
|
||||
followupStatusLabel: followupStatusLabels.get(payload.followupStatus) ?? null,
|
||||
note: payload.note ?? null,
|
||||
nextFollowupDate: payload.nextFollowupDate ?? null,
|
||||
createdAt: payload.createdAt ?? row.createdAt.toISOString(),
|
||||
createdBy: payload.createdBy ?? row.userId,
|
||||
createdByName: userNames.get(payload.createdBy ?? row.userId) ?? null
|
||||
} satisfies LeadFollowupSummary;
|
||||
})
|
||||
.filter((item): item is LeadFollowupSummary => Boolean(item));
|
||||
}
|
||||
|
||||
export async function createLeadFollowup(
|
||||
leadId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CreateLeadFollowupInput,
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadFollowupSummary> {
|
||||
const lead = await assertLeadBelongsToOrganization(leadId, organizationId, accessContext);
|
||||
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
LEAD_OPTION_CATEGORIES.followupStatus,
|
||||
payload.followupStatus
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const followupId = crypto.randomUUID();
|
||||
const auditPayload: LeadFollowupAuditPayload = {
|
||||
id: followupId,
|
||||
leadId,
|
||||
followupDate: new Date(payload.followupDate).toISOString(),
|
||||
followupStatus: payload.followupStatus,
|
||||
note: payload.note?.trim() || null,
|
||||
nextFollowupDate: payload.nextFollowupDate
|
||||
? new Date(payload.nextFollowupDate).toISOString()
|
||||
: null,
|
||||
createdBy: userId,
|
||||
createdAt: now.toISOString()
|
||||
};
|
||||
|
||||
await db
|
||||
.update(crmLeads)
|
||||
.set({
|
||||
followupStatus: payload.followupStatus,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(crmLeads.id, lead.id));
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: lead.branchId,
|
||||
userId,
|
||||
entityType: 'crm_lead',
|
||||
entityId: leadId,
|
||||
action: 'create_lead_followup',
|
||||
afterData: auditPayload
|
||||
});
|
||||
|
||||
const [userRow, followupStatuses] = await Promise.all([
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, userId)
|
||||
}),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId })
|
||||
]);
|
||||
const labelMap = new Map(followupStatuses.map((item) => [item.id, item.label]));
|
||||
|
||||
return {
|
||||
id: followupId,
|
||||
leadId,
|
||||
followupDate: auditPayload.followupDate,
|
||||
followupStatus: payload.followupStatus,
|
||||
followupStatusLabel: labelMap.get(payload.followupStatus) ?? null,
|
||||
note: auditPayload.note,
|
||||
nextFollowupDate: auditPayload.nextFollowupDate,
|
||||
createdAt: auditPayload.createdAt,
|
||||
createdBy: userId,
|
||||
createdByName: userRow?.name ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLeadAccessContext(input: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
membership: {
|
||||
role: string;
|
||||
businessRole: string;
|
||||
businessRoles?: string[];
|
||||
permissions?: string[] | null;
|
||||
branchScopeIds?: string[] | null;
|
||||
productTypeScopeIds?: string[] | null;
|
||||
ownershipScope?: string | null;
|
||||
branchScopeMode?: string | null;
|
||||
productScopeMode?: string | null;
|
||||
approvalAuthority?: string | null;
|
||||
};
|
||||
}) {
|
||||
return buildCrmSecurityContext(input);
|
||||
}
|
||||
121
src/features/crm/leads/types.ts
Normal file
121
src/features/crm/leads/types.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
export interface LeadOption {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface LeadBranchOption {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface LeadReferenceData {
|
||||
awarenesses: LeadOption[];
|
||||
statuses: LeadOption[];
|
||||
followupStatuses: LeadOption[];
|
||||
lostReasons: LeadOption[];
|
||||
branches: LeadBranchOption[];
|
||||
}
|
||||
|
||||
export interface CreateLeadInput {
|
||||
branchId?: string | null;
|
||||
customerId?: string | null;
|
||||
contactId?: string | null;
|
||||
projectName?: string | null;
|
||||
projectLocation?: string | null;
|
||||
awarenessId?: string | null;
|
||||
status: string;
|
||||
followupStatus?: string | null;
|
||||
lostReason?: string | null;
|
||||
outcome?: 'open' | 'won' | 'lost';
|
||||
ownerMarketingUserId?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateLeadInput {
|
||||
branchId?: string | null;
|
||||
customerId?: string | null;
|
||||
contactId?: string | null;
|
||||
projectName?: string | null;
|
||||
projectLocation?: string | null;
|
||||
awarenessId?: string | null;
|
||||
status?: string;
|
||||
followupStatus?: string | null;
|
||||
lostReason?: string | null;
|
||||
outcome?: 'open' | 'won' | 'lost';
|
||||
ownerMarketingUserId?: string | null;
|
||||
}
|
||||
|
||||
export interface LeadListFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
branchId?: string;
|
||||
customerId?: string;
|
||||
status?: string;
|
||||
awarenessId?: string;
|
||||
followupStatus?: string;
|
||||
outcome?: string;
|
||||
ownerMarketingUserId?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface CreateLeadFollowupInput {
|
||||
followupDate: string;
|
||||
followupStatus: string;
|
||||
note?: string | null;
|
||||
nextFollowupDate?: string | null;
|
||||
}
|
||||
|
||||
export interface LeadSummary {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
branchId: string | null;
|
||||
code: string;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
contactId: string | null;
|
||||
contactName: string | null;
|
||||
projectName: string | null;
|
||||
projectLocation: string | null;
|
||||
awarenessId: string | null;
|
||||
awarenessLabel: string | null;
|
||||
status: string;
|
||||
statusLabel: string | null;
|
||||
followupStatus: string | null;
|
||||
followupStatusLabel: string | null;
|
||||
lostReason: string | null;
|
||||
lostReasonLabel: string | null;
|
||||
outcome: string;
|
||||
ownerMarketingUserId: string | null;
|
||||
ownerMarketingName: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface LeadDetail extends LeadSummary {
|
||||
suggestedSalesOwnerId: string | null;
|
||||
relatedEnquiryCount: number;
|
||||
}
|
||||
|
||||
export interface LeadListResponse {
|
||||
items: LeadSummary[];
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface LeadFollowupSummary {
|
||||
id: string;
|
||||
leadId: string;
|
||||
followupDate: string;
|
||||
followupStatus: string;
|
||||
followupStatusLabel: string | null;
|
||||
note: string | null;
|
||||
nextFollowupDate: string | null;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
createdByName: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user