/** * 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(); }); });