208 lines
6.9 KiB
TypeScript
208 lines
6.9 KiB
TypeScript
|
|
/**
|
||
|
|
* 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();
|
||
|
|
});
|
||
|
|
});
|