From f9cb8003b569e644ddb923de1fa9fa7d0977e7de Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 15:34:44 +0200 Subject: [PATCH] feat(interests): wire yachtId, enforce ownership + stage-gate - Add yachtId (optional) to createInterestSchema + listInterestsSchema (updateInterestSchema inherits it via partial() automatically). - Add assertYachtBelongsToClient helper that accepts direct client ownership OR company-represented clients with an active membership in the owning company. - createInterest + updateInterest validate yacht ownership whenever yachtId is supplied/changed. - changeInterestStage rejects moving out of stage=open with yachtId null (ValidationError). - listInterests filter supports yachtId. - Integration tests cover all 7 paths; validator test for yachtId. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/services/interests.service.ts | 153 ++++++++++---- src/lib/validators/interests.ts | 2 + tests/integration/interests-yacht.test.ts | 231 ++++++++++++++++++++++ tests/unit/validators.test.ts | 8 + 4 files changed, 351 insertions(+), 43 deletions(-) create mode 100644 tests/integration/interests-yacht.test.ts diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index 6ace9e4..e66453a 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -4,9 +4,11 @@ import { db } from '@/lib/db'; import { interests, interestTags } from '@/lib/db/schema/interests'; import { clients } from '@/lib/db/schema/clients'; import { berths } from '@/lib/db/schema/berths'; +import { yachts } from '@/lib/db/schema/yachts'; +import { companyMemberships } from '@/lib/db/schema/companies'; import { tags } from '@/lib/db/schema/system'; import { createAuditLog } from '@/lib/audit'; -import { NotFoundError, ConflictError } from '@/lib/errors'; +import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; @@ -27,6 +29,38 @@ interface AuditMeta { userAgent: string; } +// ─── Yacht ownership validator ─────────────────────────────────────────────── + +async function assertYachtBelongsToClient( + portId: string, + yachtId: string, + clientId: string, +): Promise { + const yacht = await db.query.yachts.findFirst({ + where: and(eq(yachts.id, yachtId), eq(yachts.portId, portId)), + }); + if (!yacht) throw new ValidationError('yacht not found'); + + // Direct ownership by client + if (yacht.currentOwnerType === 'client' && yacht.currentOwnerId === clientId) { + return; + } + + // Company-represented: client has active membership in the owning company + if (yacht.currentOwnerType === 'company') { + const membership = await db.query.companyMemberships.findFirst({ + where: and( + eq(companyMemberships.companyId, yacht.currentOwnerId), + eq(companyMemberships.clientId, clientId), + isNull(companyMemberships.endDate), + ), + }); + if (membership) return; + } + + throw new ValidationError('yacht does not belong to this client'); +} + // ─── BR-011: Auto-promote leadCategory ─────────────────────────────────────── async function resolveLeadCategory( @@ -59,6 +93,7 @@ export async function listInterests(portId: string, query: ListInterestsInput) { search, includeArchived, clientId, + yachtId, berthId, pipelineStage, leadCategory, @@ -71,6 +106,9 @@ export async function listInterests(portId: string, query: ListInterestsInput) { if (clientId) { filters.push(eq(interests.clientId, clientId)); } + if (yachtId) { + filters.push(eq(interests.yachtId, yachtId)); + } if (berthId) { filters.push(eq(interests.berthId, berthId)); } @@ -98,10 +136,14 @@ export async function listInterests(portId: string, query: ListInterestsInput) { const sortColumn = (() => { switch (sort) { - case 'pipelineStage': return interests.pipelineStage; - case 'leadCategory': return interests.leadCategory; - case 'createdAt': return interests.createdAt; - default: return interests.updatedAt; + case 'pipelineStage': + return interests.pipelineStage; + case 'leadCategory': + return interests.leadCategory; + case 'createdAt': + return interests.createdAt; + default: + return interests.updatedAt; } })(); @@ -122,13 +164,19 @@ export async function listInterests(portId: string, query: ListInterestsInput) { }); // Join client names and berth mooring numbers - const interestIds = (result.data as Array<{ id: string; clientId: string; berthId: string | null }>).map((i) => i.id); - const clientIds = [...new Set((result.data as Array<{ clientId: string }>).map((i) => i.clientId))]; - const berthIds = [...new Set( - (result.data as Array<{ berthId: string | null }>) - .map((i) => i.berthId) - .filter(Boolean) as string[] - )]; + const interestIds = ( + result.data as Array<{ id: string; clientId: string; berthId: string | null }> + ).map((i) => i.id); + const clientIds = [ + ...new Set((result.data as Array<{ clientId: string }>).map((i) => i.clientId)), + ]; + const berthIds = [ + ...new Set( + (result.data as Array<{ berthId: string | null }>) + .map((i) => i.berthId) + .filter(Boolean) as string[], + ), + ]; let clientsMap: Record = {}; let berthsMap: Record = {}; @@ -219,18 +267,15 @@ export async function getInterestById(id: string, portId: string) { // ─── Create ─────────────────────────────────────────────────────────────────── -export async function createInterest( - portId: string, - data: CreateInterestInput, - meta: AuditMeta, -) { +export async function createInterest(portId: string, data: CreateInterestInput, meta: AuditMeta) { + if (data.yachtId) { + await assertYachtBelongsToClient(portId, data.yachtId, data.clientId); + } + const { tagIds, ...interestData } = data; // BR-011: auto-promote leadCategory - const resolvedLeadCategory = await resolveLeadCategory( - data.clientId, - data.leadCategory, - ); + const resolvedLeadCategory = await resolveLeadCategory(data.clientId, data.leadCategory); const result = await withTransaction(async (tx) => { const [interest] = await tx @@ -243,9 +288,9 @@ export async function createInterest( .returning(); if (tagIds && tagIds.length > 0) { - await tx.insert(interestTags).values( - tagIds.map((tagId) => ({ interestId: interest!.id, tagId })), - ); + await tx + .insert(interestTags) + .values(tagIds.map((tagId) => ({ interestId: interest!.id, tagId }))); } return interest!; @@ -262,10 +307,18 @@ export async function createInterest( userAgent: meta.userAgent, }); - emitToRoom(`port:${portId}`, 'interest:created', { interestId: result.id, clientId: result.clientId, berthId: result.berthId ?? null, source: result.source ?? '' }); + emitToRoom(`port:${portId}`, 'interest:created', { + interestId: result.id, + clientId: result.clientId, + berthId: result.berthId ?? null, + source: result.source ?? '', + }); void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => - dispatchWebhookEvent(portId, 'interest:created', { interestId: result.id, clientId: result.clientId }), + dispatchWebhookEvent(portId, 'interest:created', { + interestId: result.id, + clientId: result.clientId, + }), ); return result; @@ -287,13 +340,17 @@ export async function updateInterest( throw new NotFoundError('Interest'); } + if (data.yachtId && data.yachtId !== existing.yachtId) { + await assertYachtBelongsToClient(portId, data.yachtId, existing.clientId); + } + // BR-011: auto-promote leadCategory if provided let resolvedLeadCategory = data.leadCategory; if ('leadCategory' in data) { - resolvedLeadCategory = await resolveLeadCategory( + resolvedLeadCategory = (await resolveLeadCategory( existing.clientId, data.leadCategory, - ) as typeof data.leadCategory; + )) as typeof data.leadCategory; } const updateData = { ...data, leadCategory: resolvedLeadCategory }; @@ -320,7 +377,10 @@ export async function updateInterest( userAgent: meta.userAgent, }); - emitToRoom(`port:${portId}`, 'interest:updated', { interestId: id, changedFields: Object.keys(diff) }); + emitToRoom(`port:${portId}`, 'interest:updated', { + interestId: id, + changedFields: Object.keys(diff), + }); return updated!; } @@ -341,6 +401,11 @@ export async function changeInterestStage( throw new NotFoundError('Interest'); } + // Plan: yachtId required to leave stage=open + if (existing.pipelineStage === 'open' && data.pipelineStage !== 'open' && !existing.yachtId) { + throw new ValidationError('yachtId is required before leaving stage=open'); + } + const oldStage = existing.pipelineStage; const [updated] = await db @@ -355,7 +420,10 @@ export async function changeInterestStage( if (data.pipelineStage === 'contract') milestoneUpdates.dateContractSigned = new Date(); if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = new Date(); if (Object.keys(milestoneUpdates).length > 0) { - await db.update(interests).set({ ...milestoneUpdates, updatedAt: new Date() }).where(eq(interests.id, id)); + await db + .update(interests) + .set({ ...milestoneUpdates, updatedAt: new Date() }) + .where(eq(interests.id, id)); } void createAuditLog({ @@ -419,7 +487,9 @@ export async function archiveInterest(id: string, portId: string, meta: AuditMet // BR-014: Block archive if pending EOI/contract if (existing.eoiStatus === 'waiting_for_signatures' || existing.contractStatus === 'pending') { - throw new ConflictError('Cannot archive interest with pending documents. Cancel documents first.'); + throw new ConflictError( + 'Cannot archive interest with pending documents. Cancel documents first.', + ); } await softDelete(interests, interests.id, id); @@ -480,9 +550,7 @@ export async function setInterestTags( await db.delete(interestTags).where(eq(interestTags.interestId, id)); if (tagIds.length > 0) { - await db - .insert(interestTags) - .values(tagIds.map((tagId) => ({ interestId: id, tagId }))); + await db.insert(interestTags).values(tagIds.map((tagId) => ({ interestId: id, tagId }))); } void createAuditLog({ @@ -503,12 +571,7 @@ export async function setInterestTags( // ─── Link / Unlink Berth ────────────────────────────────────────────────────── -export async function linkBerth( - id: string, - portId: string, - berthId: string, - meta: AuditMeta, -) { +export async function linkBerth(id: string, portId: string, berthId: string, meta: AuditMeta) { const existing = await db.query.interests.findFirst({ where: eq(interests.id, id), }); @@ -575,7 +638,10 @@ export async function unlinkBerth(id: string, portId: string, meta: AuditMeta) { userAgent: meta.userAgent, }); - emitToRoom(`port:${portId}`, 'interest:berthUnlinked', { interestId: id, berthId: oldBerthId ?? '' }); + emitToRoom(`port:${portId}`, 'interest:berthUnlinked', { + interestId: id, + berthId: oldBerthId ?? '', + }); return updated!; } @@ -583,9 +649,10 @@ export async function unlinkBerth(id: string, portId: string, meta: AuditMeta) { // ─── Stage Counts (for board) ──────────────────────────────────────────────── export async function getInterestStageCounts(portId: string) { - const rows = await db.select({ stage: interests.pipelineStage, count: sql`count(*)::int` }) + const rows = await db + .select({ stage: interests.pipelineStage, count: sql`count(*)::int` }) .from(interests) .where(and(eq(interests.portId, portId), isNull(interests.archivedAt))) .groupBy(interests.pipelineStage); - return Object.fromEntries(rows.map(r => [r.stage, r.count])); + return Object.fromEntries(rows.map((r) => [r.stage, r.count])); } diff --git a/src/lib/validators/interests.ts b/src/lib/validators/interests.ts index 3215575..a462020 100644 --- a/src/lib/validators/interests.ts +++ b/src/lib/validators/interests.ts @@ -7,6 +7,7 @@ import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants'; export const createInterestSchema = z.object({ clientId: z.string().min(1), + yachtId: z.string().optional(), berthId: z.string().optional(), pipelineStage: z.enum(PIPELINE_STAGES).default('open'), leadCategory: z.enum(LEAD_CATEGORIES).optional(), @@ -34,6 +35,7 @@ export const changeStageSchema = z.object({ export const listInterestsSchema = baseListQuerySchema.extend({ clientId: z.string().optional(), + yachtId: z.string().optional(), berthId: z.string().optional(), pipelineStage: z .string() diff --git a/tests/integration/interests-yacht.test.ts b/tests/integration/interests-yacht.test.ts new file mode 100644 index 0000000..bd3e24c --- /dev/null +++ b/tests/integration/interests-yacht.test.ts @@ -0,0 +1,231 @@ +/** + * interests.service yacht-ownership validation integration tests. + * + * Covers: + * - createInterest with yachtId succeeds when yacht is owned by the client + * - createInterest with yachtId rejects when yacht belongs to a different client + * - createInterest with yachtId succeeds when client is member of owning company + * - createInterest without yachtId succeeds (stage=open is allowed) + * - changeInterestStage rejects moving out of "open" when yachtId is null + * - changeInterestStage succeeds when yachtId is set + * - updateInterest validates yacht ownership when changing yachtId + * + * Uses dynamic imports (PR 8 pattern) so env is loaded before service modules + * touch `db`. + */ +import { describe, it, expect, beforeAll } from 'vitest'; + +describe('interests.service — yacht ownership validation', () => { + let createInterest: typeof import('@/lib/services/interests.service').createInterest; + + let updateInterest: typeof import('@/lib/services/interests.service').updateInterest; + + let changeInterestStage: typeof import('@/lib/services/interests.service').changeInterestStage; + + let makePort: typeof import('../helpers/factories').makePort; + + let makeClient: typeof import('../helpers/factories').makeClient; + + let makeYacht: typeof import('../helpers/factories').makeYacht; + + let makeCompany: typeof import('../helpers/factories').makeCompany; + + let makeMembership: typeof import('../helpers/factories').makeMembership; + + let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta; + + beforeAll(async () => { + const svc = await import('@/lib/services/interests.service'); + createInterest = svc.createInterest; + updateInterest = svc.updateInterest; + changeInterestStage = svc.changeInterestStage; + + const factories = await import('../helpers/factories'); + makePort = factories.makePort; + makeClient = factories.makeClient; + makeYacht = factories.makeYacht; + makeCompany = factories.makeCompany; + makeMembership = factories.makeMembership; + makeAuditMeta = factories.makeAuditMeta; + }); + + it('createInterest with yachtId succeeds when yacht is owned by the client', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + }); + + const interest = await createInterest( + port.id, + { + clientId: client.id, + yachtId: yacht.id, + pipelineStage: 'open', + tagIds: [], + reminderEnabled: false, + }, + makeAuditMeta({ portId: port.id }), + ); + expect(interest.yachtId).toBe(yacht.id); + expect(interest.clientId).toBe(client.id); + }); + + it('createInterest with yachtId rejects when yacht belongs to a different client', async () => { + const port = await makePort(); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientA.id, + }); + + await expect( + createInterest( + port.id, + { + clientId: clientB.id, + yachtId: yacht.id, + pipelineStage: 'open', + tagIds: [], + reminderEnabled: false, + }, + makeAuditMeta({ portId: port.id }), + ), + ).rejects.toThrow(/yacht does not belong to this client/); + }); + + it('createInterest with yachtId succeeds when client is member of owning company', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const company = await makeCompany({ portId: port.id }); + await makeMembership({ + companyId: company.id, + clientId: client.id, + role: 'director', + endDate: null, + }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'company', + ownerId: company.id, + }); + + const interest = await createInterest( + port.id, + { + clientId: client.id, + yachtId: yacht.id, + pipelineStage: 'open', + tagIds: [], + reminderEnabled: false, + }, + makeAuditMeta({ portId: port.id }), + ); + expect(interest.yachtId).toBe(yacht.id); + }); + + it('createInterest without yachtId succeeds (stage=open is allowed)', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + + const interest = await createInterest( + port.id, + { clientId: client.id, pipelineStage: 'open', tagIds: [], reminderEnabled: false }, + makeAuditMeta({ portId: port.id }), + ); + expect(interest.yachtId).toBeNull(); + expect(interest.pipelineStage).toBe('open'); + }); + + it('changeInterestStage rejects moving out of "open" when yachtId is null', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const interest = await createInterest( + port.id, + { clientId: client.id, pipelineStage: 'open', tagIds: [], reminderEnabled: false }, + makeAuditMeta({ portId: port.id }), + ); + + await expect( + changeInterestStage( + interest.id, + port.id, + { pipelineStage: 'details_sent' }, + makeAuditMeta({ portId: port.id }), + ), + ).rejects.toThrow(/yachtId is required before leaving stage=open/); + }); + + it('changeInterestStage succeeds when yachtId is set', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + }); + const interest = await createInterest( + port.id, + { + clientId: client.id, + yachtId: yacht.id, + pipelineStage: 'open', + tagIds: [], + reminderEnabled: false, + }, + makeAuditMeta({ portId: port.id }), + ); + + const updated = await changeInterestStage( + interest.id, + port.id, + { pipelineStage: 'details_sent' }, + makeAuditMeta({ portId: port.id }), + ); + expect(updated.pipelineStage).toBe('details_sent'); + }); + + it('updateInterest validates yacht ownership when changing yachtId', async () => { + const port = await makePort(); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + // Interest is owned by clientA; yacht belongs to clientB. + const interest = await createInterest( + port.id, + { clientId: clientA.id, pipelineStage: 'open', tagIds: [], reminderEnabled: false }, + makeAuditMeta({ portId: port.id }), + ); + const yachtOfB = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientB.id, + }); + + await expect( + updateInterest( + interest.id, + port.id, + { yachtId: yachtOfB.id }, + makeAuditMeta({ portId: port.id }), + ), + ).rejects.toThrow(/yacht does not belong to this client/); + + // ... and succeeds when swapping in a yacht that clientA actually owns. + const yachtOfA = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientA.id, + }); + const updated = await updateInterest( + interest.id, + port.id, + { yachtId: yachtOfA.id }, + makeAuditMeta({ portId: port.id }), + ); + expect(updated.yachtId).toBe(yachtOfA.id); + }); +}); diff --git a/tests/unit/validators.test.ts b/tests/unit/validators.test.ts index 370abbf..8cdb716 100644 --- a/tests/unit/validators.test.ts +++ b/tests/unit/validators.test.ts @@ -131,6 +131,14 @@ describe('createInterestSchema', () => { const result = createInterestSchema.safeParse({ clientId: 'c1', reminderDays: 0 }); expect(result.success).toBe(false); }); + + it('createInterestSchema accepts yachtId', () => { + const result = createInterestSchema.safeParse({ + clientId: 'c1', + yachtId: 'y1', + }); + expect(result.success).toBe(true); + }); }); describe('changeStageSchema', () => {