diff --git a/src/app/api/v1/inquiries/[id]/convert/route.ts b/src/app/api/v1/inquiries/[id]/convert/route.ts new file mode 100644 index 00000000..d2310da6 --- /dev/null +++ b/src/app/api/v1/inquiries/[id]/convert/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/inquiries/[id]/route.ts b/src/app/api/v1/inquiries/[id]/route.ts new file mode 100644 index 00000000..ddc966ff --- /dev/null +++ b/src/app/api/v1/inquiries/[id]/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/inquiries/[id]/triage/route.ts b/src/app/api/v1/inquiries/[id]/triage/route.ts new file mode 100644 index 00000000..26ceb84f --- /dev/null +++ b/src/app/api/v1/inquiries/[id]/triage/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/inquiries/route.ts b/src/app/api/v1/inquiries/route.ts new file mode 100644 index 00000000..2e2a3bdd --- /dev/null +++ b/src/app/api/v1/inquiries/route.ts @@ -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); + } + }), +); diff --git a/src/lib/services/inquiries.service.ts b/src/lib/services/inquiries.service.ts new file mode 100644 index 00000000..1d3bad32 --- /dev/null +++ b/src/lib/services/inquiries.service.ts @@ -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({ + 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 { + const fields = extractInquiryFields((row.payload ?? {}) as Record); + 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, + }); +} diff --git a/src/lib/validators/inquiries.ts b/src/lib/validators/inquiries.ts new file mode 100644 index 00000000..e19da28d --- /dev/null +++ b/src/lib/validators/inquiries.ts @@ -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; +export type TriageInquiryInput = z.infer; +export type ConvertInquiryInput = z.infer; diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 30d2ba1b..808de3b0 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -30,6 +30,7 @@ export async function teardown() { ) -- 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_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_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) diff --git a/tests/integration/inquiries.service.test.ts b/tests/integration/inquiries.service.test.ts new file mode 100644 index 00000000..fdb3174a --- /dev/null +++ b/tests/integration/inquiries.service.test.ts @@ -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; + } = {}, +) { + 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(); + }); +});