Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
EOI uploads from 'qualified' silently skipped the stage flip. Now also
writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
pdf/templates/{interest,client}-summary, interest-picker, timeline route
all route through canonicalizeStage / stageLabelFor.
Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
(deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
pipeline-column (kanban), interest-columns (list), interest-card,
interest-detail (breadcrumb), client-pipeline-summary +
client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.
Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
canonical BERTH_STATUSES); cleaned from dashboard.service,
dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
"Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
more 2-line wraps on "needs date range"); accepts initialRange?:
DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
rangeToBounds.
Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
berths (where the only active deal touching the berth IS this same
interest). Waits for all competing-queries before committing the
count. Was showing "3 berths unavailable" when only 1 actually had a
competitor.
Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
instead of firstAt so visible timestamp matches the sort key.
Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.
EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
one batched getAllBerthMooringsForInterests call across all groups.
AggregatedFile type + EntityFolderView render the badge linking back
to the parent interest.
External EOI upload dialog
- Title input pre-fills from the derived default via controlled
displayTitle = title || defaultTitle (no setState-in-effect).
EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
with tooltip: the primary IS the canonical "berth for this deal",
excluding it is semantically nonsense.
Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
whenever is_primary=true; update path coerces back to true when the
caller tries to set false on a primary. Backfilled 7 existing rows.
Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
documenso_redirect_url → public_site_url → null. Operators with
public_site_url configured (most ports) now get sensible signer
landing without setting two settings.
World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
filtered Clients page via router.push instead of copying a URL to
clipboard.
Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
folder has children. Lets reps drill into subfolders from the main
content area, not only via the sidebar tree.
Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
param). Interest list passes updatedAt desc so the table header
surfaces the active sort visibly + most-recently-added/edited bubble
to the top.
Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
— explicit input → port's default_new_interest_owner setting →
creator (when not super-admin). Super-admins skipped since they often
create on behalf of other reps.
Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
flipped to true.
Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed
Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
import { and, eq, isNull } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
|
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
|
import { interests } from '@/lib/db/schema/interests';
|
|
import { makePort } from '../helpers/factories';
|
|
import { makeMockRequest } from '../helpers/route-tester';
|
|
|
|
// Mock fire-and-forget side-effects so the test doesn't hit Redis / external services.
|
|
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
|
vi.mock('@/lib/queue', () => ({
|
|
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
|
|
}));
|
|
vi.mock('@/lib/services/inquiry-notifications.service', () => ({
|
|
sendInquiryNotifications: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
// The rate-limiter is keyed by IP header and is now redis-backed; entries
|
|
// pexpire after the publicForm window (1h). Randomize the high octets so a
|
|
// fresh test run doesn't collide with leftover redis state from a previous
|
|
// run sharing the same redis instance.
|
|
const IP_PREFIX = `10.${Math.floor(Math.random() * 200) + 10}`;
|
|
let ipCounter = 1;
|
|
function uniqueIp(): string {
|
|
ipCounter += 1;
|
|
return `${IP_PREFIX}.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`;
|
|
}
|
|
|
|
describe('POST /api/public/interests - trio creation', () => {
|
|
let POST: typeof import('@/app/api/public/interests/route').POST;
|
|
|
|
beforeAll(async () => {
|
|
// Import after mocks are registered.
|
|
const mod = await import('@/app/api/public/interests/route');
|
|
POST = mod.POST;
|
|
});
|
|
|
|
it('creates client + yacht + interest atomically', async () => {
|
|
const port = await makePort();
|
|
const email = `trio-client-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
|
|
|
const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, {
|
|
headers: { 'x-forwarded-for': uniqueIp() },
|
|
body: {
|
|
firstName: 'Alice',
|
|
lastName: 'Mariner',
|
|
email,
|
|
phone: '+10000000001',
|
|
yacht: {
|
|
name: 'Sea Star',
|
|
lengthFt: 52,
|
|
widthFt: 14,
|
|
draftFt: 6,
|
|
},
|
|
},
|
|
});
|
|
const res = await POST(req);
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as any;
|
|
const interestId: string = body.data.id;
|
|
|
|
const [interest] = await db.select().from(interests).where(eq(interests.id, interestId));
|
|
expect(interest).toBeDefined();
|
|
expect(interest!.portId).toBe(port.id);
|
|
expect(interest!.pipelineStage).toBe('enquiry');
|
|
expect(interest!.yachtId).not.toBeNull();
|
|
expect(interest!.clientId).not.toBeNull();
|
|
|
|
// Yacht exists, owned by the client
|
|
const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!));
|
|
expect(yacht).toBeDefined();
|
|
expect(yacht!.name).toBe('Sea Star');
|
|
expect(yacht!.currentOwnerType).toBe('client');
|
|
expect(yacht!.currentOwnerId).toBe(interest!.clientId);
|
|
|
|
// Ownership history row created
|
|
const historyRows = await db
|
|
.select()
|
|
.from(yachtOwnershipHistory)
|
|
.where(eq(yachtOwnershipHistory.yachtId, yacht!.id));
|
|
expect(historyRows.length).toBe(1);
|
|
expect(historyRows[0]!.endDate).toBeNull();
|
|
expect(historyRows[0]!.ownerType).toBe('client');
|
|
expect(historyRows[0]!.ownerId).toBe(interest!.clientId);
|
|
|
|
// Client has email + phone contacts
|
|
const contacts = await db
|
|
.select()
|
|
.from(clientContacts)
|
|
.where(eq(clientContacts.clientId, interest!.clientId));
|
|
expect(contacts.some((c) => c.channel === 'email' && c.value === email)).toBe(true);
|
|
expect(contacts.some((c) => c.channel === 'phone' && c.value === '+10000000001')).toBe(true);
|
|
});
|
|
|
|
it('creates client + company + membership + company-owned yacht + interest when company provided', async () => {
|
|
const port = await makePort();
|
|
const email = `trio-co-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
|
const companyName = `Nautical Holdings ${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, {
|
|
headers: { 'x-forwarded-for': uniqueIp() },
|
|
body: {
|
|
firstName: 'Bob',
|
|
lastName: 'Director',
|
|
email,
|
|
phone: '+10000000002',
|
|
yacht: { name: 'Corporate Cruiser', lengthFt: 80 },
|
|
company: {
|
|
name: companyName,
|
|
role: 'director',
|
|
},
|
|
},
|
|
});
|
|
const res = await POST(req);
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as any;
|
|
const interestId: string = body.data.id;
|
|
|
|
const [interest] = await db.select().from(interests).where(eq(interests.id, interestId));
|
|
expect(interest).toBeDefined();
|
|
expect(interest!.yachtId).not.toBeNull();
|
|
|
|
// Yacht owned by the company
|
|
const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!));
|
|
expect(yacht!.currentOwnerType).toBe('company');
|
|
|
|
// Company exists and matches
|
|
const [company] = await db
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, yacht!.currentOwnerId));
|
|
expect(company!.name).toBe(companyName);
|
|
expect(company!.portId).toBe(port.id);
|
|
|
|
// Ownership-history points at the company
|
|
const historyRows = await db
|
|
.select()
|
|
.from(yachtOwnershipHistory)
|
|
.where(eq(yachtOwnershipHistory.yachtId, yacht!.id));
|
|
expect(historyRows.length).toBe(1);
|
|
expect(historyRows[0]!.ownerType).toBe('company');
|
|
expect(historyRows[0]!.ownerId).toBe(company!.id);
|
|
|
|
// Active membership linking client -> company
|
|
const memberships = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company!.id),
|
|
eq(companyMemberships.clientId, interest!.clientId),
|
|
isNull(companyMemberships.endDate),
|
|
),
|
|
);
|
|
expect(memberships.length).toBe(1);
|
|
expect(memberships[0]!.role).toBe('director');
|
|
});
|
|
|
|
it('reuses existing client when email matches (same port)', async () => {
|
|
const port = await makePort();
|
|
const email = `trio-reuse-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
|
|
|
const firstReq = makeMockRequest(
|
|
'POST',
|
|
`http://localhost/api/public/interests?portId=${port.id}`,
|
|
{
|
|
headers: { 'x-forwarded-for': uniqueIp() },
|
|
body: {
|
|
firstName: 'Carol',
|
|
lastName: 'Returning',
|
|
email,
|
|
phone: '+10000000003',
|
|
yacht: { name: 'First Boat' },
|
|
},
|
|
},
|
|
);
|
|
const firstRes = await POST(firstReq);
|
|
expect(firstRes.status).toBe(201);
|
|
const firstBody = (await firstRes.json()) as any;
|
|
|
|
const [firstInterest] = await db
|
|
.select()
|
|
.from(interests)
|
|
.where(eq(interests.id, firstBody.data.id));
|
|
const originalClientId = firstInterest!.clientId;
|
|
|
|
// Second submission with the same email
|
|
const secondReq = makeMockRequest(
|
|
'POST',
|
|
`http://localhost/api/public/interests?portId=${port.id}`,
|
|
{
|
|
headers: { 'x-forwarded-for': uniqueIp() },
|
|
body: {
|
|
firstName: 'Carol',
|
|
lastName: 'Returning',
|
|
email,
|
|
phone: '+10000000003',
|
|
yacht: { name: 'Second Boat' },
|
|
},
|
|
},
|
|
);
|
|
const secondRes = await POST(secondReq);
|
|
expect(secondRes.status).toBe(201);
|
|
const secondBody = (await secondRes.json()) as any;
|
|
|
|
const [secondInterest] = await db
|
|
.select()
|
|
.from(interests)
|
|
.where(eq(interests.id, secondBody.data.id));
|
|
expect(secondInterest!.clientId).toBe(originalClientId);
|
|
|
|
// A second yacht row was created (not deduped) - each submission is its
|
|
// own inquiry about a possibly-different yacht.
|
|
const clientsMatching = await db.select().from(clients).where(eq(clients.id, originalClientId));
|
|
expect(clientsMatching.length).toBe(1);
|
|
|
|
const [secondYacht] = await db
|
|
.select()
|
|
.from(yachts)
|
|
.where(eq(yachts.id, secondInterest!.yachtId!));
|
|
expect(secondYacht!.name).toBe('Second Boat');
|
|
expect(secondYacht!.id).not.toBe(firstInterest!.yachtId);
|
|
});
|
|
|
|
it('reuses existing company when name matches case-insensitively (same port)', async () => {
|
|
const port = await makePort();
|
|
const email1 = `trio-coreuse1-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
|
const email2 = `trio-coreuse2-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
|
const companyName = `Harbor Partners ${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
const firstReq = makeMockRequest(
|
|
'POST',
|
|
`http://localhost/api/public/interests?portId=${port.id}`,
|
|
{
|
|
headers: { 'x-forwarded-for': uniqueIp() },
|
|
body: {
|
|
firstName: 'Dana',
|
|
lastName: 'Founder',
|
|
email: email1,
|
|
phone: '+10000000004',
|
|
yacht: { name: 'Flagship' },
|
|
company: { name: companyName, role: 'director' },
|
|
},
|
|
},
|
|
);
|
|
const firstRes = await POST(firstReq);
|
|
expect(firstRes.status).toBe(201);
|
|
const firstBody = (await firstRes.json()) as any;
|
|
const [firstInterest] = await db
|
|
.select()
|
|
.from(interests)
|
|
.where(eq(interests.id, firstBody.data.id));
|
|
const [firstYacht] = await db
|
|
.select()
|
|
.from(yachts)
|
|
.where(eq(yachts.id, firstInterest!.yachtId!));
|
|
const originalCompanyId = firstYacht!.currentOwnerId;
|
|
|
|
// Second submission - same company name, different casing, different client
|
|
const secondReq = makeMockRequest(
|
|
'POST',
|
|
`http://localhost/api/public/interests?portId=${port.id}`,
|
|
{
|
|
headers: { 'x-forwarded-for': uniqueIp() },
|
|
body: {
|
|
firstName: 'Evan',
|
|
lastName: 'Employee',
|
|
email: email2,
|
|
phone: '+10000000005',
|
|
yacht: { name: 'Second Flagship' },
|
|
company: { name: companyName.toUpperCase(), role: 'employee' },
|
|
},
|
|
},
|
|
);
|
|
const secondRes = await POST(secondReq);
|
|
expect(secondRes.status).toBe(201);
|
|
const secondBody = (await secondRes.json()) as any;
|
|
const [secondInterest] = await db
|
|
.select()
|
|
.from(interests)
|
|
.where(eq(interests.id, secondBody.data.id));
|
|
const [secondYacht] = await db
|
|
.select()
|
|
.from(yachts)
|
|
.where(eq(yachts.id, secondInterest!.yachtId!));
|
|
expect(secondYacht!.currentOwnerId).toBe(originalCompanyId);
|
|
|
|
// Only one company row exists for that (portId, lowered name)
|
|
const allCompanies = await db.select().from(companies).where(eq(companies.portId, port.id));
|
|
const matching = allCompanies.filter((c) => c.name.toLowerCase() === companyName.toLowerCase());
|
|
expect(matching.length).toBe(1);
|
|
|
|
// Second client has its own membership in the same company
|
|
const memberships = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, originalCompanyId),
|
|
eq(companyMemberships.clientId, secondInterest!.clientId),
|
|
isNull(companyMemberships.endDate),
|
|
),
|
|
);
|
|
expect(memberships.length).toBe(1);
|
|
expect(memberships[0]!.role).toBe('employee');
|
|
});
|
|
});
|