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:
2026-06-17 18:09:59 +02:00
parent 9879b82e5f
commit 54554a0928
8 changed files with 582 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { convertInquiryToClient, convertInquiryToInterest } from '@/lib/services/inquiries.service';
import { convertInquirySchema } from '@/lib/validators/inquiries';
export const POST = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { target } = await parseBody(req, convertInquirySchema);
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
const data =
target === 'interest'
? await convertInquiryToInterest(id, ctx.portId, meta)
: await convertInquiryToClient(id, ctx.portId, meta);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { getInquiryById } from '@/lib/services/inquiries.service';
export const GET = withAuth(
withPermission('inquiries', 'view', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const data = await getInquiryById(id, ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { triageInquiry } from '@/lib/services/inquiries.service';
import { triageInquirySchema } from '@/lib/validators/inquiries';
export const PATCH = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { state } = await parseBody(req, triageInquirySchema);
const data = await triageInquiry(id, ctx.portId, state, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listInquiries } from '@/lib/services/inquiries.service';
import { listInquiriesSchema } from '@/lib/validators/inquiries';
export const GET = withAuth(
withPermission('inquiries', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listInquiriesSchema);
const result = await listInquiries(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

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

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/list-query';
/**
* List query for the inquiries workbench (over `website_submissions`).
* `state` defaults to 'inbox' (open + assigned) so resolved/dismissed roll off
* the active queue; pass 'all' for the full history.
*/
export const listInquiriesSchema = baseListQuerySchema.extend({
kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']).optional(),
state: z.enum(['inbox', 'open', 'assigned', 'converted', 'dismissed', 'all']).default('inbox'),
});
export const triageInquirySchema = z.object({
state: z.enum(['open', 'assigned', 'converted', 'dismissed']),
});
export const convertInquirySchema = z.object({
target: z.enum(['client', 'interest']),
});
export type ListInquiriesInput = z.infer<typeof listInquiriesSchema>;
export type TriageInquiryInput = z.infer<typeof triageInquirySchema>;
export type ConvertInquiryInput = z.infer<typeof convertInquirySchema>;