diff --git a/src/lib/services/yachts.service.ts b/src/lib/services/yachts.service.ts index 7652dcd..0679cd0 100644 --- a/src/lib/services/yachts.service.ts +++ b/src/lib/services/yachts.service.ts @@ -1,17 +1,20 @@ -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq, ilike, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { yachts, yachtOwnershipHistory, clients } from '@/lib/db/schema'; +import type { Yacht } from '@/lib/db/schema/yachts'; import { companies } from '@/lib/db/schema/companies'; import { createAuditLog } from '@/lib/audit'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { diffEntity } from '@/lib/entity-diff'; +import { buildListQuery } from '@/lib/db/query-builder'; import { withTransaction } from '@/lib/db/utils'; import type { z } from 'zod'; import type { createYachtSchema, UpdateYachtInput, TransferOwnershipInput, + ListYachtsInput, } from '@/lib/validators/yachts'; type CreateYachtInput = z.input; @@ -263,3 +266,74 @@ export async function transferOwnership( return updated!; }); } + +// ─── List ───────────────────────────────────────────────────────────────────── + +export async function listYachts(portId: string, query: ListYachtsInput) { + const { page, limit, sort, order, search, includeArchived, ownerType, ownerId, status } = query; + + const filters = []; + if (ownerType) filters.push(eq(yachts.currentOwnerType, ownerType)); + if (ownerId) filters.push(eq(yachts.currentOwnerId, ownerId)); + if (status) filters.push(eq(yachts.status, status)); + + let sortColumn: typeof yachts.name | typeof yachts.createdAt | typeof yachts.updatedAt = + yachts.updatedAt; + if (sort === 'name') sortColumn = yachts.name; + else if (sort === 'createdAt') sortColumn = yachts.createdAt; + + const result = await buildListQuery({ + table: yachts, + portIdColumn: yachts.portId, + portId, + idColumn: yachts.id, + updatedAtColumn: yachts.updatedAt, + searchColumns: [yachts.name, yachts.hullNumber, yachts.registration], + searchTerm: search, + filters, + sort: sort ? { column: sortColumn, direction: order } : undefined, + page, + pageSize: limit, + includeArchived, + archivedAtColumn: yachts.archivedAt, + }); + + return result; +} + +// ─── List for owner ─────────────────────────────────────────────────────────── + +export async function listYachtsForOwner( + portId: string, + ownerType: 'client' | 'company', + ownerId: string, +) { + return await db.query.yachts.findMany({ + where: and( + eq(yachts.portId, portId), + eq(yachts.currentOwnerType, ownerType), + eq(yachts.currentOwnerId, ownerId), + ), + orderBy: (t, { desc }) => [desc(t.updatedAt)], + }); +} + +// ─── Autocomplete ───────────────────────────────────────────────────────────── + +export async function autocomplete(portId: string, q: string) { + const pattern = `%${q}%`; + return await db + .select() + .from(yachts) + .where( + and( + eq(yachts.portId, portId), + or( + ilike(yachts.name, pattern), + ilike(yachts.hullNumber, pattern), + ilike(yachts.registration, pattern), + ), + ), + ) + .limit(10); +} diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index 87df389..188f9fb 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -72,15 +72,22 @@ export async function makeYacht(args: { portId: string; ownerType: 'client' | 'company'; ownerId: string; + name?: string; + status?: 'active' | 'retired' | 'sold_away'; + hullNumber?: string; + registration?: string; overrides?: Partial; }): Promise { const [yacht] = await db .insert(yachts) .values({ portId: args.portId, - name: args.overrides?.name ?? `Yacht ${Math.random().toString(36).slice(2, 8)}`, + name: args.name ?? args.overrides?.name ?? `Yacht ${Math.random().toString(36).slice(2, 8)}`, currentOwnerType: args.ownerType, currentOwnerId: args.ownerId, + ...(args.status !== undefined ? { status: args.status } : {}), + ...(args.hullNumber !== undefined ? { hullNumber: args.hullNumber } : {}), + ...(args.registration !== undefined ? { registration: args.registration } : {}), ...args.overrides, }) .returning(); diff --git a/tests/unit/services/yachts.test.ts b/tests/unit/services/yachts.test.ts index 3e8ba4f..46b24ee 100644 --- a/tests/unit/services/yachts.test.ts +++ b/tests/unit/services/yachts.test.ts @@ -1,6 +1,19 @@ import { describe, it, expect } from 'vitest'; -import { createYacht, updateYacht, archiveYacht } from '@/lib/services/yachts.service'; -import { makeClient, makePort, makeYacht, makeAuditMeta } from '../../helpers/factories'; +import { + createYacht, + updateYacht, + archiveYacht, + listYachts, + listYachtsForOwner, + autocomplete, +} from '@/lib/services/yachts.service'; +import { + makeClient, + makeCompany, + makePort, + makeYacht, + makeAuditMeta, +} from '../../helpers/factories'; import { db } from '@/lib/db'; import { yachts, yachtOwnershipHistory } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; @@ -160,3 +173,188 @@ describe('yachts.service — archiveYacht', () => { await expect(archiveYacht(yachtInB.id, portA.id, makeAuditMeta())).rejects.toThrow(/yacht/i); }); }); + +describe('yachts.service — listYachts', () => { + it('is scoped to port (tenant isolation)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const clientA = await makeClient({ portId: portA.id }); + const clientB = await makeClient({ portId: portB.id }); + await makeYacht({ portId: portA.id, ownerType: 'client', ownerId: clientA.id, name: 'In A' }); + await makeYacht({ portId: portB.id, ownerType: 'client', ownerId: clientB.id, name: 'In B' }); + + const result = await listYachts(portA.id, { + page: 1, + limit: 20, + order: 'desc', + includeArchived: false, + }); + expect(result.data.some((y) => y.name === 'In A')).toBe(true); + expect(result.data.some((y) => y.name === 'In B')).toBe(false); + }); + + it('filters by ownerType + ownerId', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const other = await makeClient({ portId: port.id }); + await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Mine' }); + await makeYacht({ portId: port.id, ownerType: 'client', ownerId: other.id, name: 'Theirs' }); + + const result = await listYachts(port.id, { + page: 1, + limit: 20, + order: 'desc', + includeArchived: false, + ownerType: 'client', + ownerId: client.id, + }); + expect(result.data.map((y) => y.name)).toContain('Mine'); + expect(result.data.map((y) => y.name)).not.toContain('Theirs'); + }); + + it('filters by status', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Active One', + status: 'active', + }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Retired One', + status: 'retired', + }); + + const result = await listYachts(port.id, { + page: 1, + limit: 20, + order: 'desc', + includeArchived: false, + status: 'retired', + }); + expect(result.data.map((y) => y.name)).toContain('Retired One'); + expect(result.data.map((y) => y.name)).not.toContain('Active One'); + }); + + it('searches by name (ILIKE)', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Sea Breeze', + }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Wind Dancer', + }); + + const result = await listYachts(port.id, { + page: 1, + limit: 20, + order: 'desc', + includeArchived: false, + search: 'breeze', + }); + expect(result.data.map((y) => y.name)).toContain('Sea Breeze'); + expect(result.data.map((y) => y.name)).not.toContain('Wind Dancer'); + }); +}); + +describe('yachts.service — listYachtsForOwner', () => { + it('returns all yachts owned by a given client', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Y1' }); + await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Y2' }); + + const result = await listYachtsForOwner(port.id, 'client', client.id); + expect(result.map((y) => y.name).sort()).toEqual(['Y1', 'Y2']); + }); + + it('returns all yachts owned by a given company', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + await makeYacht({ portId: port.id, ownerType: 'company', ownerId: company.id, name: 'CY1' }); + + const result = await listYachtsForOwner(port.id, 'company', company.id); + expect(result.map((y) => y.name)).toEqual(['CY1']); + }); + + it('is tenant-scoped', async () => { + const portA = await makePort(); + const portB = await makePort(); + const clientA = await makeClient({ portId: portA.id }); + await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: clientA.id, + name: 'Only-A', + }); + + const result = await listYachtsForOwner(portB.id, 'client', clientA.id); + expect(result).toHaveLength(0); + }); +}); + +describe('yachts.service — autocomplete', () => { + it('matches by name (ILIKE)', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Phoenix' }); + + const result = await autocomplete(port.id, 'phoe'); + expect(result.some((y) => y.name === 'Phoenix')).toBe(true); + }); + + it('matches by hullNumber or registration', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Something', + hullNumber: 'HULL-ABC-123', + }); + + const result = await autocomplete(port.id, 'HULL-ABC'); + expect(result.some((y) => y.hullNumber === 'HULL-ABC-123')).toBe(true); + }); + + it('is tenant-scoped and caps at 10 results', async () => { + const port = await makePort(); + const other = await makePort(); + const client = await makeClient({ portId: port.id }); + const otherClient = await makeClient({ portId: other.id }); + + for (let i = 0; i < 12; i++) { + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: `MatchMe-${i}`, + }); + } + await makeYacht({ + portId: other.id, + ownerType: 'client', + ownerId: otherClient.id, + name: 'MatchMe-other', + }); + + const result = await autocomplete(port.id, 'matchme'); + expect(result.length).toBe(10); + expect(result.every((y) => y.name.startsWith('MatchMe-') && !y.name.endsWith('-other'))).toBe( + true, + ); + }); +});