feat(inquiries): list/get/triage/convert service + API routes (find-or-create client)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
242
src/lib/services/inquiries.service.ts
Normal file
242
src/lib/services/inquiries.service.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Inquiries workbench service — read/triage/convert over `website_submissions`.
|
||||
*
|
||||
* The capture endpoint (`/api/public/website-inquiries`) writes raw submissions;
|
||||
* this service is the operator-facing layer: list/filter, triage state changes,
|
||||
* and converting an inquiry into proper CRM entities (client and/or interest)
|
||||
* with the submission row linked back to what it produced.
|
||||
*/
|
||||
|
||||
import { and, eq, inArray, isNull, sql, type SQL } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||
import { createClient } from './clients.service';
|
||||
import { createInterest } from './interests.service';
|
||||
import { extractInquiryFields } from './website-intake-fields';
|
||||
import { createClientSchema } from '@/lib/validators/clients';
|
||||
import { createInterestSchema } from '@/lib/validators/interests';
|
||||
import type { ListInquiriesInput } from '@/lib/validators/inquiries';
|
||||
|
||||
type TriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
|
||||
|
||||
const SORTABLE = {
|
||||
receivedAt: websiteSubmissions.receivedAt,
|
||||
kind: websiteSubmissions.kind,
|
||||
triageState: websiteSubmissions.triageState,
|
||||
contactName: websiteSubmissions.contactName,
|
||||
} as const;
|
||||
|
||||
export async function listInquiries(portId: string, query: ListInquiriesInput) {
|
||||
const filters: SQL[] = [];
|
||||
if (query.kind) filters.push(eq(websiteSubmissions.kind, query.kind));
|
||||
if (query.state === 'inbox') {
|
||||
filters.push(inArray(websiteSubmissions.triageState, ['open', 'assigned']));
|
||||
} else if (query.state !== 'all') {
|
||||
filters.push(eq(websiteSubmissions.triageState, query.state));
|
||||
}
|
||||
|
||||
const sortColumn =
|
||||
query.sort && query.sort in SORTABLE
|
||||
? SORTABLE[query.sort as keyof typeof SORTABLE]
|
||||
: undefined;
|
||||
|
||||
return buildListQuery<typeof websiteSubmissions.$inferSelect>({
|
||||
table: websiteSubmissions,
|
||||
portIdColumn: websiteSubmissions.portId,
|
||||
portId,
|
||||
idColumn: websiteSubmissions.id,
|
||||
// website_submissions has no updatedAt; receivedAt is the natural clock and
|
||||
// the deterministic tail-sort.
|
||||
updatedAtColumn: websiteSubmissions.receivedAt,
|
||||
searchColumns: [websiteSubmissions.contactName, websiteSubmissions.contactEmail],
|
||||
searchTerm: query.search,
|
||||
filters,
|
||||
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
|
||||
page: query.page,
|
||||
pageSize: query.limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInquiryById(id: string, portId: string) {
|
||||
const row = await loadInquiry(id, portId);
|
||||
|
||||
const convertedClient = row.convertedClientId
|
||||
? ((
|
||||
await db
|
||||
.select({ id: clients.id, fullName: clients.fullName })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, row.convertedClientId))
|
||||
.limit(1)
|
||||
)[0] ?? null)
|
||||
: null;
|
||||
|
||||
const convertedInterest = row.convertedInterestId
|
||||
? ((
|
||||
await db
|
||||
.select({ id: interests.id, pipelineStage: interests.pipelineStage })
|
||||
.from(interests)
|
||||
.where(eq(interests.id, row.convertedInterestId))
|
||||
.limit(1)
|
||||
)[0] ?? null)
|
||||
: null;
|
||||
|
||||
return { ...row, convertedClient, convertedInterest };
|
||||
}
|
||||
|
||||
export async function triageInquiry(
|
||||
id: string,
|
||||
portId: string,
|
||||
state: TriageState,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const [updated] = await db
|
||||
.update(websiteSubmissions)
|
||||
.set({ triageState: state, triagedAt: new Date(), triagedBy: meta.userId })
|
||||
.where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, portId)))
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('inquiry');
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'website_submission',
|
||||
entityId: id,
|
||||
fieldChanged: 'triageState',
|
||||
newValue: { triageState: state },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function convertInquiryToClient(id: string, portId: string, meta: AuditMeta) {
|
||||
const row = await loadInquiry(id, portId);
|
||||
// Idempotent: if already linked to a client, return it rather than duplicate.
|
||||
if (row.convertedClientId) return { clientId: row.convertedClientId, interestId: null };
|
||||
|
||||
const clientId = await findOrCreateClientFromInquiry(row, meta);
|
||||
await markConverted(id, portId, { clientId }, meta);
|
||||
return { clientId, interestId: null };
|
||||
}
|
||||
|
||||
export async function convertInquiryToInterest(id: string, portId: string, meta: AuditMeta) {
|
||||
const row = await loadInquiry(id, portId);
|
||||
if (row.convertedInterestId) {
|
||||
throw new ConflictError('Inquiry has already been converted to an interest.');
|
||||
}
|
||||
|
||||
const clientId = row.convertedClientId ?? (await findOrCreateClientFromInquiry(row, meta));
|
||||
|
||||
const interestData = createInterestSchema.parse({
|
||||
clientId,
|
||||
pipelineStage: 'enquiry',
|
||||
source: 'website',
|
||||
});
|
||||
const interest = await createInterest(portId, interestData, meta);
|
||||
|
||||
await markConverted(id, portId, { clientId, interestId: interest.id }, meta);
|
||||
return { clientId, interestId: interest.id };
|
||||
}
|
||||
|
||||
// ─── internals ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadInquiry(id: string, portId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(websiteSubmissions)
|
||||
.where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, portId)))
|
||||
.limit(1);
|
||||
if (!row) throw new NotFoundError('inquiry');
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single in-port client whose email contact matches the inquiry's email,
|
||||
* else create a new client from the payload. Returns the client id.
|
||||
*/
|
||||
async function findOrCreateClientFromInquiry(
|
||||
row: typeof websiteSubmissions.$inferSelect,
|
||||
meta: AuditMeta,
|
||||
): Promise<string> {
|
||||
const fields = extractInquiryFields((row.payload ?? {}) as Record<string, unknown>);
|
||||
const email = (row.contactEmail ?? fields.email ?? '').trim();
|
||||
|
||||
if (email) {
|
||||
const matches = await db
|
||||
.selectDistinct({ id: clients.id })
|
||||
.from(clients)
|
||||
.innerJoin(clientContacts, eq(clientContacts.clientId, clients.id))
|
||||
.where(
|
||||
and(
|
||||
eq(clients.portId, meta.portId),
|
||||
isNull(clients.archivedAt),
|
||||
eq(clientContacts.channel, 'email'),
|
||||
sql`lower(${clientContacts.value}) = ${email.toLowerCase()}`,
|
||||
),
|
||||
)
|
||||
.limit(2);
|
||||
// Only auto-link on an unambiguous single match.
|
||||
if (matches.length === 1) return matches[0]!.id;
|
||||
}
|
||||
|
||||
const contacts: Array<{
|
||||
channel: 'email' | 'phone' | 'other';
|
||||
value: string;
|
||||
isPrimary?: boolean;
|
||||
}> = [];
|
||||
if (email) contacts.push({ channel: 'email', value: email, isPrimary: true });
|
||||
const phone = (fields.phone ?? '').trim();
|
||||
if (phone) contacts.push({ channel: 'phone', value: phone });
|
||||
if (contacts.length === 0) {
|
||||
// Schema requires ≥1 contact; fall back to a name-bearing "other" contact.
|
||||
contacts.push({ channel: 'other', value: row.contactName ?? 'Website inquiry' });
|
||||
}
|
||||
|
||||
const fullName = (row.contactName ?? fields.fullName ?? email ?? '').trim() || 'Website inquiry';
|
||||
|
||||
const clientData = createClientSchema.parse({
|
||||
fullName,
|
||||
contacts,
|
||||
source: 'website',
|
||||
sourceInquiryId: row.id,
|
||||
});
|
||||
const client = await createClient(meta.portId, clientData, meta);
|
||||
return client.id;
|
||||
}
|
||||
|
||||
async function markConverted(
|
||||
id: string,
|
||||
portId: string,
|
||||
refs: { clientId: string; interestId?: string },
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
await db
|
||||
.update(websiteSubmissions)
|
||||
.set({
|
||||
convertedClientId: refs.clientId,
|
||||
...(refs.interestId ? { convertedInterestId: refs.interestId } : {}),
|
||||
triageState: 'converted',
|
||||
triagedAt: new Date(),
|
||||
triagedBy: meta.userId,
|
||||
})
|
||||
.where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'website_submission',
|
||||
entityId: id,
|
||||
fieldChanged: 'triageState',
|
||||
newValue: { triageState: 'converted', ...refs },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user