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: <AddressesEditor> 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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 19:38:43 +02:00
parent 27cdbcc695
commit 46937bbcb9
12 changed files with 1117 additions and 5 deletions

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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 {

View File

@@ -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
<ClientReservationsTab clientId={clientId} activeReservations={client.activeReservations} />
),
},
{
id: 'addresses',
label: 'Addresses',
badge: client.addresses?.length ?? 0,
content: (
<AddressesEditor
endpoint={`/api/v1/clients/${clientId}/addresses`}
invalidateKey={['clients', clientId]}
addresses={client.addresses ?? []}
/>
),
},
{
id: 'interests',
label: 'Interests',

View File

@@ -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 {

View File

@@ -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: (
<EmptyState
title="Addresses"
description="Company addresses coming soon — the addresses endpoint is pending wiring."
<AddressesEditor
endpoint={`/api/v1/companies/${companyId}/addresses`}
invalidateKey={['companies', companyId]}
addresses={company.addresses ?? []}
/>
),
},

View File

@@ -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<Omit<Address, 'id'>>;
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 (
<div className="space-y-2">
{addresses.length === 0 && !adding && (
<p className="text-sm text-muted-foreground">No addresses yet</p>
)}
{addresses.map((a) => (
<AddressCard
key={a.id}
address={a}
onUpdate={(patch) => updateMutation.mutateAsync({ id: a.id, patch })}
onRemove={async () => {
if (!confirm('Remove this address?')) return;
await removeMutation.mutateAsync(a.id);
}}
/>
))}
{adding ? (
<NewAddressForm
isFirst={addresses.length === 0}
onCancel={() => setAdding(false)}
onSave={async (data) => {
await addMutation.mutateAsync(data);
setAdding(false);
}}
/>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdding(true)}
className="w-full justify-center"
data-testid="add-address-button"
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add address
</Button>
)}
</div>
);
}
function AddressCard({
address,
onUpdate,
onRemove,
}: {
address: Address;
onUpdate: (patch: AddressPatch) => Promise<unknown>;
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 (
<div className="group rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<InlineEditableField
value={address.label}
placeholder="Label (e.g. Home, Office)"
onSave={async (v) => {
if (!v) {
toast.error('Label is required');
return;
}
await onUpdate({ label: v });
}}
/>
</div>
<button
type="button"
onClick={togglePrimary}
title={address.isPrimary ? 'Primary address' : 'Make primary'}
className={cn(
'p-1 rounded hover:bg-background/60 transition-colors',
address.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)}
data-testid="address-primary-toggle"
>
<Star className={cn('h-3.5 w-3.5', address.isPrimary && 'fill-current')} />
</button>
<button
type="button"
onClick={onRemove}
title="Remove"
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
data-testid="address-remove-button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 pl-5">
<Field label="Street">
<InlineEditableField
value={address.streetAddress}
placeholder="123 Main St"
onSave={async (v) => {
await onUpdate({ streetAddress: v });
}}
/>
</Field>
<Field label="City">
<InlineEditableField
value={address.city}
placeholder="City"
onSave={async (v) => {
await onUpdate({ city: v });
}}
/>
</Field>
<Field label="Country">
<CountryFieldInline
value={address.countryIso}
onSave={async (iso) => {
// 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);
}}
/>
</Field>
<Field label="Region">
<SubdivisionFieldInline
value={address.subdivisionIso}
country={(address.countryIso as CountryCode | null) ?? null}
onSave={async (code) => {
await onUpdate({ subdivisionIso: code });
}}
/>
</Field>
<Field label="Postal Code">
<InlineEditableField
value={address.postalCode}
placeholder="ZIP / Postal"
onSave={async (v) => {
await onUpdate({ postalCode: v });
}}
/>
</Field>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-baseline gap-2">
<span className="text-xs text-muted-foreground w-20 shrink-0">{label}</span>
<span className="flex-1 min-w-0">{children}</span>
</div>
);
}
function CountryFieldInline({
value,
onSave,
}: {
value: string | null;
onSave: (iso: string | null) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<CountryCombobox
value={value ?? null}
onChange={async (iso) => {
setEditing(false);
await onSave(iso ?? null);
}}
clearable
className="w-full"
/>
);
}
const display = value ? getCountryName(value, 'en') : null;
return (
<button
type="button"
onClick={() => setEditing(true)}
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
>
{display ?? <span className="text-muted-foreground italic">Not set</span>}
</button>
);
}
function SubdivisionFieldInline({
value,
country,
onSave,
}: {
value: string | null;
country: CountryCode | null;
onSave: (code: string | null) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<SubdivisionCombobox
value={value ?? null}
country={country}
onChange={async (code) => {
setEditing(false);
await onSave(code ?? null);
}}
clearable
className="w-full"
/>
);
}
if (!country) {
return <span className="text-muted-foreground italic text-xs">Pick country first</span>;
}
const display = value ? getSubdivisionName(value) : null;
return (
<button
type="button"
onClick={() => setEditing(true)}
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
>
{display ?? <span className="text-muted-foreground italic">Not set</span>}
</button>
);
}
function NewAddressForm({
onSave,
onCancel,
isFirst,
}: {
onSave: (data: AddressPatch) => Promise<void>;
onCancel: () => void;
isFirst: boolean;
}) {
const [label, setLabel] = useState('Primary');
const [streetAddress, setStreet] = useState('');
const [city, setCity] = useState('');
const [countryIso, setCountryIso] = useState<string | null>(null);
const [subdivisionIso, setSubdivisionIso] = useState<string | null>(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 (
<div className="rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Label (Home, Office)"
className="h-8"
autoFocus
disabled={saving}
/>
<Input
value={streetAddress}
onChange={(e) => setStreet(e.target.value)}
placeholder="Street address"
className="h-8"
disabled={saving}
/>
<Input
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="City"
className="h-8"
disabled={saving}
/>
<CountryCombobox
value={countryIso}
onChange={(iso) => {
setCountryIso(iso ?? null);
setSubdivisionIso(null);
}}
clearable
placeholder="Country"
/>
<SubdivisionCombobox
value={subdivisionIso}
country={(countryIso as CountryCode | null) ?? null}
onChange={(code) => setSubdivisionIso(code ?? null)}
clearable
placeholder="Region (optional)"
/>
<Input
value={postalCode}
onChange={(e) => setPostal(e.target.value)}
placeholder="Postal code"
className="h-8"
disabled={saving}
/>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={makePrimary}
onChange={(e) => setMakePrimary(e.target.checked)}
disabled={saving}
/>
Set as primary address
</label>
<div className="flex gap-2">
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
Cancel
</Button>
<Button type="button" size="sm" onClick={submit} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -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(

View File

@@ -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'] });
}