+ );
+}
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);
+ });
+});