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:
@@ -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(
|
||||
|
||||
@@ -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'] });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user