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,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)
|
||||
|
||||
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