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:
30
src/app/api/v1/inquiries/[id]/convert/route.ts
Normal file
30
src/app/api/v1/inquiries/[id]/convert/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
18
src/app/api/v1/inquiries/[id]/route.ts
Normal file
18
src/app/api/v1/inquiries/[id]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
26
src/app/api/v1/inquiries/[id]/triage/route.ts
Normal file
26
src/app/api/v1/inquiries/[id]/triage/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
33
src/app/api/v1/inquiries/route.ts
Normal file
33
src/app/api/v1/inquiries/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
25
src/lib/validators/inquiries.ts
Normal file
25
src/lib/validators/inquiries.ts
Normal 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>;
|
||||||
@@ -30,6 +30,7 @@ export async function teardown() {
|
|||||||
)
|
)
|
||||||
-- Cascade-delete dependent rows. Order respects FK chains.
|
-- Cascade-delete dependent rows. Order respects FK chains.
|
||||||
, del_audit AS (DELETE FROM audit_logs WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
, del_audit AS (DELETE FROM audit_logs WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||||
|
, del_ws AS (DELETE FROM website_submissions WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||||
, del_bml AS (DELETE FROM berth_maintenance_log WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
, del_bml AS (DELETE FROM berth_maintenance_log WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||||
, del_resv AS (DELETE FROM berth_tenancies WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
, del_resv AS (DELETE FROM berth_tenancies WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||||
, del_caddr AS (DELETE FROM client_addresses WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
, del_caddr AS (DELETE FROM client_addresses WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||||
|
|||||||
207
tests/integration/inquiries.service.test.ts
Normal file
207
tests/integration/inquiries.service.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Inquiries workbench service: list/filter/triage/get + convert-to-client and
|
||||||
|
* convert-to-interest (find-or-create client, tracking columns, port isolation).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
vi.mock('@/lib/socket/server', () => ({
|
||||||
|
emitToRoom: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 { user } from '@/lib/db/schema/users';
|
||||||
|
import {
|
||||||
|
listInquiries,
|
||||||
|
getInquiryById,
|
||||||
|
triageInquiry,
|
||||||
|
convertInquiryToClient,
|
||||||
|
convertInquiryToInterest,
|
||||||
|
} from '@/lib/services/inquiries.service';
|
||||||
|
import { makePort } from '../helpers/factories';
|
||||||
|
import type { AuditMeta } from '@/lib/audit';
|
||||||
|
|
||||||
|
let META: AuditMeta;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const [u] = await db.select({ id: user.id }).from(user).limit(1);
|
||||||
|
if (!u) throw new Error('No user available; run pnpm db:seed first');
|
||||||
|
META = { userId: u.id, portId: '', ipAddress: '127.0.0.1', userAgent: 'test' };
|
||||||
|
});
|
||||||
|
|
||||||
|
function metaFor(portId: string): AuditMeta {
|
||||||
|
return { ...META, portId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedInquiry(
|
||||||
|
portId: string,
|
||||||
|
opts: {
|
||||||
|
kind?: 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
|
||||||
|
triageState?: string;
|
||||||
|
contactName?: string | null;
|
||||||
|
contactEmail?: string | null;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(websiteSubmissions)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
submissionId: crypto.randomUUID(),
|
||||||
|
kind: opts.kind ?? 'contact_form',
|
||||||
|
payload: opts.payload ?? {},
|
||||||
|
contactName: opts.contactName ?? 'Jane Doe',
|
||||||
|
contactEmail: opts.contactEmail ?? 'jane@example.com',
|
||||||
|
triageState: opts.triageState ?? 'open',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('inquiries.service — list / get / triage', () => {
|
||||||
|
it('filters by kind and state, searches name/email, scoped to port', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const other = await makePort();
|
||||||
|
await seedInquiry(port.id, {
|
||||||
|
kind: 'contact_form',
|
||||||
|
contactName: 'Alice Smith',
|
||||||
|
contactEmail: 'alice@x.com',
|
||||||
|
});
|
||||||
|
await seedInquiry(port.id, {
|
||||||
|
kind: 'berth_inquiry',
|
||||||
|
contactName: 'Bob Jones',
|
||||||
|
contactEmail: 'bob@x.com',
|
||||||
|
});
|
||||||
|
await seedInquiry(port.id, {
|
||||||
|
kind: 'contact_form',
|
||||||
|
triageState: 'dismissed',
|
||||||
|
contactName: 'Carol',
|
||||||
|
});
|
||||||
|
await seedInquiry(other.id, {
|
||||||
|
kind: 'contact_form',
|
||||||
|
contactName: 'Alice Smith',
|
||||||
|
contactEmail: 'alice@x.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = { page: 1, limit: 25, order: 'desc' as const, includeArchived: false };
|
||||||
|
|
||||||
|
// inbox (open+assigned) excludes the dismissed one
|
||||||
|
const inbox = await listInquiries(port.id, { ...base, state: 'inbox' });
|
||||||
|
expect(inbox.total).toBe(2);
|
||||||
|
|
||||||
|
// kind filter
|
||||||
|
const contacts = await listInquiries(port.id, { ...base, state: 'all', kind: 'contact_form' });
|
||||||
|
expect(contacts.total).toBe(2);
|
||||||
|
|
||||||
|
// search by name
|
||||||
|
const alice = await listInquiries(port.id, { ...base, state: 'all', search: 'alice' });
|
||||||
|
expect(alice.total).toBe(1);
|
||||||
|
expect(alice.data[0]!.contactName).toBe('Alice Smith');
|
||||||
|
|
||||||
|
// port isolation
|
||||||
|
expect(alice.data.every((r) => r.portId === port.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triageInquiry updates state + triagedBy; getInquiryById returns the row', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const row = await seedInquiry(port.id);
|
||||||
|
const updated = await triageInquiry(row.id, port.id, 'assigned', metaFor(port.id));
|
||||||
|
expect(updated.triageState).toBe('assigned');
|
||||||
|
expect(updated.triagedBy).toBe(META.userId);
|
||||||
|
|
||||||
|
const fetched = await getInquiryById(row.id, port.id);
|
||||||
|
expect(fetched.id).toBe(row.id);
|
||||||
|
expect(fetched.convertedClient).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inquiries.service — convert', () => {
|
||||||
|
it('convert to client creates a new client when no email match', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const row = await seedInquiry(port.id, {
|
||||||
|
contactName: 'New Lead',
|
||||||
|
contactEmail: 'newlead@example.com',
|
||||||
|
payload: {
|
||||||
|
first_name: 'New',
|
||||||
|
last_name: 'Lead',
|
||||||
|
email: 'newlead@example.com',
|
||||||
|
phone: '+15551234567',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await convertInquiryToClient(row.id, port.id, metaFor(port.id));
|
||||||
|
expect(res.clientId).toBeTruthy();
|
||||||
|
expect(res.interestId).toBeNull();
|
||||||
|
|
||||||
|
const [c] = await db.select().from(clients).where(eq(clients.id, res.clientId)).limit(1);
|
||||||
|
expect(c!.fullName).toBe('New Lead');
|
||||||
|
expect(c!.source).toBe('website');
|
||||||
|
|
||||||
|
const sub = await getInquiryById(row.id, port.id);
|
||||||
|
expect(sub.triageState).toBe('converted');
|
||||||
|
expect(sub.convertedClientId).toBe(res.clientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('convert links an existing client on a unique email match (no duplicate)', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
// Pre-existing client with the same email.
|
||||||
|
const [existing] = await db
|
||||||
|
.insert(clients)
|
||||||
|
.values({ portId: port.id, fullName: 'Existing Client', source: 'manual' })
|
||||||
|
.returning();
|
||||||
|
await db
|
||||||
|
.insert(clientContacts)
|
||||||
|
.values({
|
||||||
|
clientId: existing!.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'dup@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = await seedInquiry(port.id, {
|
||||||
|
contactEmail: 'dup@example.com',
|
||||||
|
payload: { email: 'dup@example.com' },
|
||||||
|
});
|
||||||
|
const res = await convertInquiryToClient(row.id, port.id, metaFor(port.id));
|
||||||
|
expect(res.clientId).toBe(existing!.id);
|
||||||
|
|
||||||
|
const all = await db.select().from(clients).where(eq(clients.portId, port.id));
|
||||||
|
expect(all).toHaveLength(1); // no duplicate created
|
||||||
|
});
|
||||||
|
|
||||||
|
it('convert to interest find-or-creates the client and creates an interest', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const row = await seedInquiry(port.id, {
|
||||||
|
contactName: 'Deal Maker',
|
||||||
|
contactEmail: 'deal@example.com',
|
||||||
|
payload: { first_name: 'Deal', last_name: 'Maker', email: 'deal@example.com' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await convertInquiryToInterest(row.id, port.id, metaFor(port.id));
|
||||||
|
expect(res.clientId).toBeTruthy();
|
||||||
|
expect(res.interestId).toBeTruthy();
|
||||||
|
|
||||||
|
const [i] = await db.select().from(interests).where(eq(interests.id, res.interestId!)).limit(1);
|
||||||
|
expect(i!.clientId).toBe(res.clientId);
|
||||||
|
expect(i!.pipelineStage).toBe('enquiry');
|
||||||
|
|
||||||
|
const sub = await getInquiryById(row.id, port.id);
|
||||||
|
expect(sub.triageState).toBe('converted');
|
||||||
|
expect(sub.convertedInterestId).toBe(res.interestId);
|
||||||
|
expect(sub.convertedClientId).toBe(res.clientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('convert to interest twice is rejected', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const row = await seedInquiry(port.id, {
|
||||||
|
contactEmail: 'once@example.com',
|
||||||
|
payload: { email: 'once@example.com' },
|
||||||
|
});
|
||||||
|
await convertInquiryToInterest(row.id, port.id, metaFor(port.id));
|
||||||
|
await expect(convertInquiryToInterest(row.id, port.id, metaFor(port.id))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user