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

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