From 46937bbcb97ddf929287d4878ffb3e762164e34e Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 28 Apr 2026 19:38:43 +0200 Subject: [PATCH] feat(addresses): full CRUD UI for client + company multi-address Client and company detail pages each gain an Addresses tab with click-to-edit fields wired to the existing CountryCombobox/SubdivisionCombobox primitives. Adds a primary toggle that demotes the previous primary inside one transaction so the partial unique index never trips. - New service helpers: list/add/update/remove ClientAddress + CompanyAddress - New routes: /api/v1/clients/[id]/addresses[/addressId], same under companies/ - New shared component: reused by both detail surfaces - Integration tests cover happy path, primary demotion, and tenant scoping Tests: 747/747 vitest (was 741, +6 address tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[id]/addresses/[addressId]/route.ts | 51 +++ .../api/v1/clients/[id]/addresses/route.ts | 46 ++ .../[id]/addresses/[addressId]/route.ts | 51 +++ .../api/v1/companies/[id]/addresses/route.ts | 46 ++ src/components/clients/client-detail.tsx | 2 + src/components/clients/client-tabs.tsx | 14 + src/components/companies/company-detail.tsx | 3 + src/components/companies/company-tabs.tsx | 10 +- src/components/shared/addresses-editor.tsx | 411 ++++++++++++++++++ src/lib/services/clients.service.ts | 150 ++++++- src/lib/services/companies.service.ts | 144 +++++- tests/integration/addresses.test.ts | 194 +++++++++ 12 files changed, 1117 insertions(+), 5 deletions(-) create mode 100644 src/app/api/v1/clients/[id]/addresses/[addressId]/route.ts create mode 100644 src/app/api/v1/clients/[id]/addresses/route.ts create mode 100644 src/app/api/v1/companies/[id]/addresses/[addressId]/route.ts create mode 100644 src/app/api/v1/companies/[id]/addresses/route.ts create mode 100644 src/components/shared/addresses-editor.tsx create mode 100644 tests/integration/addresses.test.ts diff --git a/src/app/api/v1/clients/[id]/addresses/[addressId]/route.ts b/src/app/api/v1/clients/[id]/addresses/[addressId]/route.ts new file mode 100644 index 0000000..26babd6 --- /dev/null +++ b/src/app/api/v1/clients/[id]/addresses/[addressId]/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { updateClientAddress, removeClientAddress } from '@/lib/services/clients.service'; +import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n'; + +const updateAddressSchema = z.object({ + label: z.string().min(1).max(80).optional(), + streetAddress: z.string().max(500).optional().nullable(), + city: z.string().max(120).optional().nullable(), + subdivisionIso: optionalSubdivisionIsoSchema.optional(), + postalCode: z.string().max(40).optional().nullable(), + countryIso: optionalCountryIsoSchema.optional(), + isPrimary: z.boolean().optional(), +}); + +export const PATCH = withAuth( + withPermission('clients', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, updateAddressSchema); + const row = await updateClientAddress(params.addressId!, params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: row }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('clients', 'edit', async (req, ctx, params) => { + try { + await removeClientAddress(params.addressId!, params.id!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/clients/[id]/addresses/route.ts b/src/app/api/v1/clients/[id]/addresses/route.ts new file mode 100644 index 0000000..ecf4b41 --- /dev/null +++ b/src/app/api/v1/clients/[id]/addresses/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { listClientAddresses, addClientAddress } from '@/lib/services/clients.service'; +import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n'; + +const addAddressSchema = z.object({ + label: z.string().min(1).max(80).optional(), + streetAddress: z.string().max(500).optional().nullable(), + city: z.string().max(120).optional().nullable(), + subdivisionIso: optionalSubdivisionIsoSchema.optional(), + postalCode: z.string().max(40).optional().nullable(), + countryIso: optionalCountryIsoSchema.optional(), + isPrimary: z.boolean().optional(), +}); + +export const GET = withAuth( + withPermission('clients', 'view', async (req, ctx, params) => { + try { + const rows = await listClientAddresses(params.id!, ctx.portId); + return NextResponse.json({ data: rows }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('clients', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, addAddressSchema); + const row = await addClientAddress(params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: row }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/companies/[id]/addresses/[addressId]/route.ts b/src/app/api/v1/companies/[id]/addresses/[addressId]/route.ts new file mode 100644 index 0000000..2a56677 --- /dev/null +++ b/src/app/api/v1/companies/[id]/addresses/[addressId]/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { updateCompanyAddress, removeCompanyAddress } from '@/lib/services/companies.service'; +import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n'; + +const updateAddressSchema = z.object({ + label: z.string().min(1).max(80).optional(), + streetAddress: z.string().max(500).optional().nullable(), + city: z.string().max(120).optional().nullable(), + subdivisionIso: optionalSubdivisionIsoSchema.optional(), + postalCode: z.string().max(40).optional().nullable(), + countryIso: optionalCountryIsoSchema.optional(), + isPrimary: z.boolean().optional(), +}); + +export const PATCH = withAuth( + withPermission('companies', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, updateAddressSchema); + const row = await updateCompanyAddress(params.addressId!, params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: row }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('companies', 'edit', async (req, ctx, params) => { + try { + await removeCompanyAddress(params.addressId!, params.id!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/companies/[id]/addresses/route.ts b/src/app/api/v1/companies/[id]/addresses/route.ts new file mode 100644 index 0000000..e071164 --- /dev/null +++ b/src/app/api/v1/companies/[id]/addresses/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { listCompanyAddresses, addCompanyAddress } from '@/lib/services/companies.service'; +import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n'; + +const addAddressSchema = z.object({ + label: z.string().min(1).max(80).optional(), + streetAddress: z.string().max(500).optional().nullable(), + city: z.string().max(120).optional().nullable(), + subdivisionIso: optionalSubdivisionIsoSchema.optional(), + postalCode: z.string().max(40).optional().nullable(), + countryIso: optionalCountryIsoSchema.optional(), + isPrimary: z.boolean().optional(), +}); + +export const GET = withAuth( + withPermission('companies', 'view', async (req, ctx, params) => { + try { + const rows = await listCompanyAddresses(params.id!, ctx.portId); + return NextResponse.json({ data: rows }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('companies', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, addAddressSchema); + const row = await addCompanyAddress(params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: row }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/clients/client-detail.tsx b/src/components/clients/client-detail.tsx index bfe72b8..3de3f97 100644 --- a/src/components/clients/client-detail.tsx +++ b/src/components/clients/client-detail.tsx @@ -7,6 +7,7 @@ import { ClientDetailHeader } from '@/components/clients/client-detail-header'; import { getClientTabs } from '@/components/clients/client-tabs'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; +import type { Address } from '@/components/shared/addresses-editor'; interface ClientData { id: string; @@ -64,6 +65,7 @@ interface ClientData { tenureType: string; status: string; }>; + addresses: Address[]; } interface ClientDetailProps { diff --git a/src/components/clients/client-tabs.tsx b/src/components/clients/client-tabs.tsx index 5de89fb..8be018a 100644 --- a/src/components/clients/client-tabs.tsx +++ b/src/components/clients/client-tabs.tsx @@ -13,6 +13,7 @@ import { ClientYachtsTab } from '@/components/clients/client-yachts-tab'; import { ClientCompaniesTab } from '@/components/clients/client-companies-tab'; import { ClientReservationsTab } from '@/components/clients/client-reservations-tab'; import { ContactsEditor } from '@/components/clients/contacts-editor'; +import { AddressesEditor, type Address } from '@/components/shared/addresses-editor'; import { apiFetch } from '@/lib/api/client'; type ClientPatchField = @@ -83,6 +84,7 @@ interface ClientTabsOptions { label?: string | null; isPrimary: boolean; }>; + addresses?: Address[]; yachts: Array<{ id: string; name: string; @@ -237,6 +239,18 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt ), }, + { + id: 'addresses', + label: 'Addresses', + badge: client.addresses?.length ?? 0, + content: ( + + ), + }, { id: 'interests', label: 'Interests', diff --git a/src/components/companies/company-detail.tsx b/src/components/companies/company-detail.tsx index 41c7ec8..f2da262 100644 --- a/src/components/companies/company-detail.tsx +++ b/src/components/companies/company-detail.tsx @@ -8,6 +8,7 @@ import { CompanyDetailHeader } from '@/components/companies/company-detail-heade import { getCompanyTabs } from '@/components/companies/company-tabs'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; +import type { Address } from '@/components/shared/addresses-editor'; export interface CompanyData { id: string; @@ -25,6 +26,8 @@ export interface CompanyData { archivedAt: string | null; createdAt: string; updatedAt: string; + tags?: Array<{ id: string; name: string; color: string }>; + addresses?: Address[]; } interface CompanyDetailProps { diff --git a/src/components/companies/company-tabs.tsx b/src/components/companies/company-tabs.tsx index 4e76cde..4a5feaa 100644 --- a/src/components/companies/company-tabs.tsx +++ b/src/components/companies/company-tabs.tsx @@ -11,6 +11,7 @@ import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { NotesList } from '@/components/shared/notes-list'; import { CompanyMembersTab } from '@/components/companies/company-members-tab'; import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab'; +import { AddressesEditor, type Address } from '@/components/shared/addresses-editor'; import { apiFetch } from '@/lib/api/client'; import type { CountryCode } from '@/lib/i18n/countries'; @@ -45,6 +46,7 @@ interface CompanyTabsCompany { billingEmail: string | null; notes: string | null; tags?: Array<{ id: string; name: string; color: string }>; + addresses?: Address[]; } interface CompanyTabsOptions { @@ -211,10 +213,12 @@ export function getCompanyTabs({ { id: 'addresses', label: 'Addresses', + badge: company.addresses?.length ?? 0, content: ( - ), }, diff --git a/src/components/shared/addresses-editor.tsx b/src/components/shared/addresses-editor.tsx new file mode 100644 index 0000000..e3c9302 --- /dev/null +++ b/src/components/shared/addresses-editor.tsx @@ -0,0 +1,411 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { CountryCombobox } from '@/components/shared/country-combobox'; +import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; +import { InlineEditableField } from '@/components/shared/inline-editable-field'; +import { apiFetch } from '@/lib/api/client'; +import type { CountryCode } from '@/lib/i18n/countries'; +import { getCountryName } from '@/lib/i18n/countries'; +import { getSubdivisionName } from '@/lib/i18n/subdivisions'; +import { cn } from '@/lib/utils'; + +export interface Address { + id: string; + label: string; + streetAddress: string | null; + city: string | null; + subdivisionIso: string | null; + postalCode: string | null; + countryIso: string | null; + isPrimary: boolean; +} + +type AddressPatch = Partial>; + +interface AddressesEditorProps { + /** Base API endpoint, e.g. `/api/v1/clients/abc/addresses` */ + endpoint: string; + /** React-Query invalidation key for the parent entity. */ + invalidateKey: readonly unknown[]; + addresses: Address[]; +} + +export function AddressesEditor({ endpoint, invalidateKey, addresses }: AddressesEditorProps) { + const qc = useQueryClient(); + const [adding, setAdding] = useState(false); + + function invalidate() { + qc.invalidateQueries({ queryKey: invalidateKey }); + } + + const updateMutation = useMutation({ + mutationFn: async ({ id, patch }: { id: string; patch: AddressPatch }) => + apiFetch(`${endpoint}/${id}`, { method: 'PATCH', body: patch }), + onSuccess: invalidate, + }); + + const addMutation = useMutation({ + mutationFn: async (data: AddressPatch) => apiFetch(endpoint, { method: 'POST', body: data }), + onSuccess: invalidate, + }); + + const removeMutation = useMutation({ + mutationFn: async (id: string) => apiFetch(`${endpoint}/${id}`, { method: 'DELETE' }), + onSuccess: invalidate, + }); + + return ( +
+ {addresses.length === 0 && !adding && ( +

No addresses yet

+ )} + + {addresses.map((a) => ( + updateMutation.mutateAsync({ id: a.id, patch })} + onRemove={async () => { + if (!confirm('Remove this address?')) return; + await removeMutation.mutateAsync(a.id); + }} + /> + ))} + + {adding ? ( + setAdding(false)} + onSave={async (data) => { + await addMutation.mutateAsync(data); + setAdding(false); + }} + /> + ) : ( + + )} +
+ ); +} + +function AddressCard({ + address, + onUpdate, + onRemove, +}: { + address: Address; + onUpdate: (patch: AddressPatch) => Promise; + onRemove: () => void; +}) { + async function togglePrimary() { + if (address.isPrimary) return; // already primary; demoting via toggle would orphan all + try { + await onUpdate({ isPrimary: true }); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update'); + } + } + + return ( +
+
+ +
+ { + if (!v) { + toast.error('Label is required'); + return; + } + await onUpdate({ label: v }); + }} + /> +
+ + +
+ +
+ + { + await onUpdate({ streetAddress: v }); + }} + /> + + + { + await onUpdate({ city: v }); + }} + /> + + + { + // Clear subdivision if country changes — codes are scoped per country. + const patch: AddressPatch = { countryIso: iso }; + if (iso !== address.countryIso) patch.subdivisionIso = null; + await onUpdate(patch); + }} + /> + + + { + await onUpdate({ subdivisionIso: code }); + }} + /> + + + { + await onUpdate({ postalCode: v }); + }} + /> + +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function CountryFieldInline({ + value, + onSave, +}: { + value: string | null; + onSave: (iso: string | null) => Promise; +}) { + const [editing, setEditing] = useState(false); + if (editing) { + return ( + { + setEditing(false); + await onSave(iso ?? null); + }} + clearable + className="w-full" + /> + ); + } + const display = value ? getCountryName(value, 'en') : null; + return ( + + ); +} + +function SubdivisionFieldInline({ + value, + country, + onSave, +}: { + value: string | null; + country: CountryCode | null; + onSave: (code: string | null) => Promise; +}) { + const [editing, setEditing] = useState(false); + if (editing) { + return ( + { + setEditing(false); + await onSave(code ?? null); + }} + clearable + className="w-full" + /> + ); + } + if (!country) { + return Pick country first; + } + const display = value ? getSubdivisionName(value) : null; + return ( + + ); +} + +function NewAddressForm({ + onSave, + onCancel, + isFirst, +}: { + onSave: (data: AddressPatch) => Promise; + onCancel: () => void; + isFirst: boolean; +}) { + const [label, setLabel] = useState('Primary'); + const [streetAddress, setStreet] = useState(''); + const [city, setCity] = useState(''); + const [countryIso, setCountryIso] = useState(null); + const [subdivisionIso, setSubdivisionIso] = useState(null); + const [postalCode, setPostal] = useState(''); + const [makePrimary, setMakePrimary] = useState(isFirst); + const [saving, setSaving] = useState(false); + + async function submit() { + if (!label.trim()) { + toast.error('Label is required'); + return; + } + setSaving(true); + try { + await onSave({ + label: label.trim(), + streetAddress: streetAddress.trim() || null, + city: city.trim() || null, + countryIso, + subdivisionIso, + postalCode: postalCode.trim() || null, + isPrimary: makePrimary, + }); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to add address'); + } finally { + setSaving(false); + } + } + + return ( +
+
+ setLabel(e.target.value)} + placeholder="Label (Home, Office)" + className="h-8" + autoFocus + disabled={saving} + /> + setStreet(e.target.value)} + placeholder="Street address" + className="h-8" + disabled={saving} + /> + setCity(e.target.value)} + placeholder="City" + className="h-8" + disabled={saving} + /> + { + setCountryIso(iso ?? null); + setSubdivisionIso(null); + }} + clearable + placeholder="Country" + /> + setSubdivisionIso(code ?? null)} + clearable + placeholder="Region (optional)" + /> + setPostal(e.target.value)} + placeholder="Postal code" + className="h-8" + disabled={saving} + /> +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts index 4e5904d..06f9b30 100644 --- a/src/lib/services/clients.service.ts +++ b/src/lib/services/clients.service.ts @@ -1,7 +1,13 @@ import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; -import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients'; +import { + clients, + clientContacts, + clientRelationships, + clientTags, + clientAddresses, +} from '@/lib/db/schema/clients'; import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { yachts } from '@/lib/db/schema/yachts'; import { berthReservations } from '@/lib/db/schema/reservations'; @@ -131,6 +137,11 @@ export async function getClientById(id: string, portId: string) { orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], }); + const addresses = await db.query.clientAddresses.findMany({ + where: eq(clientAddresses.clientId, id), + orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], + }); + const clientTagRows = await db .select({ tag: tags }) .from(clientTags) @@ -199,6 +210,7 @@ export async function getClientById(id: string, portId: string) { return { ...client, contacts, + addresses, tags: clientTagRows.map((r) => r.tag), yachts: yachtRows, companies: membershipRows, @@ -468,6 +480,142 @@ export async function removeContact( emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] }); } +// ─── Addresses ──────────────────────────────────────────────────────────────── + +interface AddressInput { + label?: string; + streetAddress?: string | null; + city?: string | null; + subdivisionIso?: string | null; + postalCode?: string | null; + countryIso?: string | null; + isPrimary?: boolean; +} + +export async function listClientAddresses(clientId: string, portId: string) { + const client = await db.query.clients.findFirst({ + where: eq(clients.id, clientId), + }); + if (!client || client.portId !== portId) throw new NotFoundError('Client'); + + return db.query.clientAddresses.findMany({ + where: eq(clientAddresses.clientId, clientId), + orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], + }); +} + +export async function addClientAddress( + clientId: string, + portId: string, + data: AddressInput, + meta: AuditMeta, +) { + const client = await db.query.clients.findFirst({ + where: eq(clients.id, clientId), + }); + if (!client || client.portId !== portId) throw new NotFoundError('Client'); + + // The unique partial index requires us to demote any existing primary + // before inserting a new one, in a single transaction. + const address = await withTransaction(async (tx) => { + const wantsPrimary = data.isPrimary ?? false; + if (wantsPrimary) { + await tx + .update(clientAddresses) + .set({ isPrimary: false }) + .where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true))); + } + const [row] = await tx + .insert(clientAddresses) + .values({ + clientId, + portId, + label: data.label ?? 'Primary', + streetAddress: data.streetAddress ?? null, + city: data.city ?? null, + subdivisionIso: data.subdivisionIso ?? null, + postalCode: data.postalCode ?? null, + countryIso: data.countryIso ?? null, + isPrimary: wantsPrimary, + }) + .returning(); + return row!; + }); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'clientAddress', + entityId: address.id, + newValue: { clientId, label: address.label, countryIso: address.countryIso }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] }); + + return address; +} + +export async function updateClientAddress( + addressId: string, + clientId: string, + portId: string, + data: AddressInput, + _meta: AuditMeta, +) { + const client = await db.query.clients.findFirst({ + where: eq(clients.id, clientId), + }); + if (!client || client.portId !== portId) throw new NotFoundError('Client'); + + const existing = await db.query.clientAddresses.findFirst({ + where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)), + }); + if (!existing) throw new NotFoundError('Address'); + + const updated = await withTransaction(async (tx) => { + if (data.isPrimary === true && !existing.isPrimary) { + await tx + .update(clientAddresses) + .set({ isPrimary: false }) + .where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true))); + } + const [row] = await tx + .update(clientAddresses) + .set({ ...data, updatedAt: new Date() }) + .where(eq(clientAddresses.id, addressId)) + .returning(); + return row!; + }); + + emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] }); + + return updated; +} + +export async function removeClientAddress( + addressId: string, + clientId: string, + portId: string, + _meta: AuditMeta, +) { + const client = await db.query.clients.findFirst({ + where: eq(clients.id, clientId), + }); + if (!client || client.portId !== portId) throw new NotFoundError('Client'); + + const address = await db.query.clientAddresses.findFirst({ + where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)), + }); + if (!address) throw new NotFoundError('Address'); + + await db.delete(clientAddresses).where(eq(clientAddresses.id, addressId)); + + emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] }); +} + // ─── Tags ───────────────────────────────────────────────────────────────────── export async function setClientTags( diff --git a/src/lib/services/companies.service.ts b/src/lib/services/companies.service.ts index 083909c..06fb938 100644 --- a/src/lib/services/companies.service.ts +++ b/src/lib/services/companies.service.ts @@ -1,6 +1,11 @@ import { and, count, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; -import { companies, companyMemberships, companyTags } from '@/lib/db/schema/companies'; +import { + companies, + companyMemberships, + companyTags, + companyAddresses, +} from '@/lib/db/schema/companies'; import type { Company } from '@/lib/db/schema/companies'; import { yachts } from '@/lib/db/schema/yachts'; import { withTransaction } from '@/lib/db/utils'; @@ -116,9 +121,16 @@ export async function getCompanyById(id: string, portId: string) { const { tags: tagJoins, ...rest } = company as typeof company & { tags: Array<{ tag: { id: string; name: string; color: string } }>; }; + + const addresses = await db.query.companyAddresses.findMany({ + where: eq(companyAddresses.companyId, id), + orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], + }); + return { ...rest, tags: tagJoins.map((t) => t.tag), + addresses, }; } @@ -371,3 +383,133 @@ export async function setCompanyTags( emitToRoom(`port:${portId}`, 'company:updated', { companyId, changedFields: ['tags'] }); } + +// ─── Addresses ──────────────────────────────────────────────────────────────── + +interface CompanyAddressInput { + label?: string; + streetAddress?: string | null; + city?: string | null; + subdivisionIso?: string | null; + postalCode?: string | null; + countryIso?: string | null; + isPrimary?: boolean; +} + +export async function listCompanyAddresses(companyId: string, portId: string) { + const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) }); + if (!company || company.portId !== portId) throw new NotFoundError('Company'); + + return db.query.companyAddresses.findMany({ + where: eq(companyAddresses.companyId, companyId), + orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], + }); +} + +export async function addCompanyAddress( + companyId: string, + portId: string, + data: CompanyAddressInput, + meta: AuditMeta, +) { + const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) }); + if (!company || company.portId !== portId) throw new NotFoundError('Company'); + + const address = await withTransaction(async (tx) => { + const wantsPrimary = data.isPrimary ?? false; + if (wantsPrimary) { + await tx + .update(companyAddresses) + .set({ isPrimary: false }) + .where( + and(eq(companyAddresses.companyId, companyId), eq(companyAddresses.isPrimary, true)), + ); + } + const [row] = await tx + .insert(companyAddresses) + .values({ + companyId, + portId, + label: data.label ?? 'Primary', + streetAddress: data.streetAddress ?? null, + city: data.city ?? null, + subdivisionIso: data.subdivisionIso ?? null, + postalCode: data.postalCode ?? null, + countryIso: data.countryIso ?? null, + isPrimary: wantsPrimary, + }) + .returning(); + return row!; + }); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'companyAddress', + entityId: address.id, + newValue: { companyId, label: address.label, countryIso: address.countryIso }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'company:updated', { companyId, changedFields: ['addresses'] }); + + return address; +} + +export async function updateCompanyAddress( + addressId: string, + companyId: string, + portId: string, + data: CompanyAddressInput, + _meta: AuditMeta, +) { + const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) }); + if (!company || company.portId !== portId) throw new NotFoundError('Company'); + + const existing = await db.query.companyAddresses.findFirst({ + where: and(eq(companyAddresses.id, addressId), eq(companyAddresses.companyId, companyId)), + }); + if (!existing) throw new NotFoundError('Address'); + + const updated = await withTransaction(async (tx) => { + if (data.isPrimary === true && !existing.isPrimary) { + await tx + .update(companyAddresses) + .set({ isPrimary: false }) + .where( + and(eq(companyAddresses.companyId, companyId), eq(companyAddresses.isPrimary, true)), + ); + } + const [row] = await tx + .update(companyAddresses) + .set({ ...data, updatedAt: new Date() }) + .where(eq(companyAddresses.id, addressId)) + .returning(); + return row!; + }); + + emitToRoom(`port:${portId}`, 'company:updated', { companyId, changedFields: ['addresses'] }); + + return updated; +} + +export async function removeCompanyAddress( + addressId: string, + companyId: string, + portId: string, + _meta: AuditMeta, +) { + const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) }); + if (!company || company.portId !== portId) throw new NotFoundError('Company'); + + const address = await db.query.companyAddresses.findFirst({ + where: and(eq(companyAddresses.id, addressId), eq(companyAddresses.companyId, companyId)), + }); + if (!address) throw new NotFoundError('Address'); + + await db.delete(companyAddresses).where(eq(companyAddresses.id, addressId)); + + emitToRoom(`port:${portId}`, 'company:updated', { companyId, changedFields: ['addresses'] }); +} diff --git a/tests/integration/addresses.test.ts b/tests/integration/addresses.test.ts new file mode 100644 index 0000000..bb178d7 --- /dev/null +++ b/tests/integration/addresses.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; + +import { db } from '@/lib/db'; +import { clientAddresses, companyAddresses } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { + listClientAddresses, + addClientAddress, + updateClientAddress, + removeClientAddress, +} from '@/lib/services/clients.service'; +import { + listCompanyAddresses, + addCompanyAddress, + updateCompanyAddress, + removeCompanyAddress, +} from '@/lib/services/companies.service'; +import { NotFoundError } from '@/lib/errors'; +import { makePort, makeClient, makeCompany } from '../helpers/factories'; + +const META = (portId: string) => ({ + userId: 'test-user', + portId, + ipAddress: '127.0.0.1', + userAgent: 'vitest', +}); + +describe('client addresses service', () => { + it('adds, lists, updates, and removes a client address', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + + // Initially empty. + const empty = await listClientAddresses(client.id, port.id); + expect(empty).toHaveLength(0); + + const added = await addClientAddress( + client.id, + port.id, + { + label: 'Home', + streetAddress: '1 Pier Rd', + city: 'Marbella', + countryIso: 'ES', + subdivisionIso: 'ES-MA', + postalCode: '29602', + isPrimary: true, + }, + META(port.id), + ); + + expect(added.label).toBe('Home'); + expect(added.countryIso).toBe('ES'); + expect(added.isPrimary).toBe(true); + + const list = await listClientAddresses(client.id, port.id); + expect(list).toHaveLength(1); + + const updated = await updateClientAddress( + added.id, + client.id, + port.id, + { city: 'Málaga' }, + META(port.id), + ); + expect(updated.city).toBe('Málaga'); + + await removeClientAddress(added.id, client.id, port.id, META(port.id)); + + const after = await listClientAddresses(client.id, port.id); + expect(after).toHaveLength(0); + }); + + it('demotes an existing primary when adding a new primary', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + + const first = await addClientAddress( + client.id, + port.id, + { label: 'Home', isPrimary: true }, + META(port.id), + ); + + const second = await addClientAddress( + client.id, + port.id, + { label: 'Office', isPrimary: true }, + META(port.id), + ); + + const rows = await db.query.clientAddresses.findMany({ + where: eq(clientAddresses.clientId, client.id), + }); + const primaries = rows.filter((r) => r.isPrimary); + expect(primaries).toHaveLength(1); + expect(primaries[0]!.id).toBe(second.id); + + // The previously-primary row is now demoted, not deleted. + const firstAfter = rows.find((r) => r.id === first.id); + expect(firstAfter?.isPrimary).toBe(false); + }); + + it('demotes other primaries when patching to primary=true', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + + const first = await addClientAddress( + client.id, + port.id, + { label: 'Home', isPrimary: true }, + META(port.id), + ); + const second = await addClientAddress( + client.id, + port.id, + { label: 'Office', isPrimary: false }, + META(port.id), + ); + + await updateClientAddress(second.id, client.id, port.id, { isPrimary: true }, META(port.id)); + + const rows = await db.query.clientAddresses.findMany({ + where: eq(clientAddresses.clientId, client.id), + }); + const primary = rows.find((r) => r.isPrimary); + expect(primary?.id).toBe(second.id); + expect(rows.find((r) => r.id === first.id)?.isPrimary).toBe(false); + }); + + it('is tenant-scoped (cross-port access throws NotFoundError)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const client = await makeClient({ portId: portA.id }); + + await expect(listClientAddresses(client.id, portB.id)).rejects.toThrow(NotFoundError); + await expect( + addClientAddress(client.id, portB.id, { label: 'X' }, META(portB.id)), + ).rejects.toThrow(NotFoundError); + }); +}); + +describe('company addresses service', () => { + it('adds, lists, updates, and removes a company address', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + + const added = await addCompanyAddress( + company.id, + port.id, + { label: 'HQ', countryIso: 'GB', isPrimary: true }, + META(port.id), + ); + expect(added.countryIso).toBe('GB'); + + const list = await listCompanyAddresses(company.id, port.id); + expect(list).toHaveLength(1); + + const updated = await updateCompanyAddress( + added.id, + company.id, + port.id, + { city: 'London' }, + META(port.id), + ); + expect(updated.city).toBe('London'); + + await removeCompanyAddress(added.id, company.id, port.id, META(port.id)); + const after = await db.query.companyAddresses.findMany({ + where: eq(companyAddresses.companyId, company.id), + }); + expect(after).toHaveLength(0); + }); + + it('demotes an existing primary when adding a new primary', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + + await addCompanyAddress(company.id, port.id, { label: 'HQ', isPrimary: true }, META(port.id)); + const second = await addCompanyAddress( + company.id, + port.id, + { label: 'Branch', isPrimary: true }, + META(port.id), + ); + + const rows = await db.query.companyAddresses.findMany({ + where: eq(companyAddresses.companyId, company.id), + }); + const primaries = rows.filter((r) => r.isPrimary); + expect(primaries).toHaveLength(1); + expect(primaries[0]!.id).toBe(second.id); + }); +});