Files
pn-new-crm/src/lib/services/clients.service.ts
Matt Ciaccio e8d61c91c4
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
  marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint

Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)

Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
  handlers.ts files (Next.js 15 route.ts only allows specific exports)

Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
  (apiFetch already JSON.stringifies its body; passing a stringified
  body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
  to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
  Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md

Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00

583 lines
18 KiB
TypeScript

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 { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { berthReservations } from '@/lib/db/schema/reservations';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
import type {
CreateClientInput,
UpdateClientInput,
ListClientsInput,
} from '@/lib/validators/clients';
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listClients(portId: string, query: ListClientsInput) {
const { page, limit, sort, order, search, includeArchived, source, nationality, tagIds } = query;
const filters = [];
if (source) {
filters.push(eq(clients.source, source));
}
if (nationality) {
filters.push(ilike(clients.nationality, `%${nationality}%`));
}
if (tagIds && tagIds.length > 0) {
const clientsWithTags = await db
.selectDistinct({ clientId: clientTags.clientId })
.from(clientTags)
.where(inArray(clientTags.tagId, tagIds));
const matchingIds = clientsWithTags.map((r) => r.clientId);
if (matchingIds.length > 0) {
filters.push(inArray(clients.id, matchingIds));
} else {
// No clients match these tags — return empty
return { data: [], total: 0 };
}
}
let sortColumn: typeof clients.fullName | typeof clients.createdAt | typeof clients.updatedAt =
clients.updatedAt;
if (sort === 'fullName') sortColumn = clients.fullName;
else if (sort === 'createdAt') sortColumn = clients.createdAt;
const result = await buildListQuery<typeof clients.$inferSelect>({
table: clients,
portIdColumn: clients.portId,
portId,
idColumn: clients.id,
updatedAtColumn: clients.updatedAt,
searchColumns: [clients.fullName],
searchTerm: search,
filters,
sort: sort ? { column: sortColumn, direction: order } : undefined,
page,
pageSize: limit,
includeArchived,
archivedAtColumn: clients.archivedAt,
});
if (result.data.length === 0) return result;
const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts] = await Promise.all([
db
.select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
inArray(yachts.currentOwnerId, ids),
isNull(yachts.archivedAt),
),
)
.groupBy(yachts.currentOwnerId),
db
.select({ clientId: companyMemberships.clientId, count: count() })
.from(companyMemberships)
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
.groupBy(companyMemberships.clientId),
]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
return {
...result,
data: result.data.map((row) => ({
...row,
yachtCount: yachtCountMap.get(row.id) ?? 0,
companyCount: companyCountMap.get(row.id) ?? 0,
})),
};
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getClientById(id: string, portId: string) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!client || client.portId !== portId) {
throw new NotFoundError('Client');
}
const contacts = await db.query.clientContacts.findMany({
where: eq(clientContacts.clientId, id),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
const clientTagRows = await db
.select({ tag: tags })
.from(clientTags)
.innerJoin(tags, eq(clientTags.tagId, tags.id))
.where(eq(clientTags.clientId, id));
const yachtRows = await db.query.yachts.findMany({
where: and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
eq(yachts.currentOwnerId, id),
isNull(yachts.archivedAt),
),
columns: {
id: true,
name: true,
hullNumber: true,
registration: true,
lengthFt: true,
widthFt: true,
status: true,
},
});
const membershipRows = await db
.select({
membershipId: companyMemberships.id,
role: companyMemberships.role,
isPrimary: companyMemberships.isPrimary,
startDate: companyMemberships.startDate,
company: {
id: companies.id,
name: companies.name,
legalName: companies.legalName,
status: companies.status,
},
})
.from(companyMemberships)
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
.where(
and(
eq(companyMemberships.clientId, id),
eq(companies.portId, portId),
isNull(companyMemberships.endDate),
),
);
const activeReservations = await db.query.berthReservations.findMany({
where: and(
eq(berthReservations.clientId, id),
eq(berthReservations.portId, portId),
eq(berthReservations.status, 'active'),
),
columns: {
id: true,
berthId: true,
yachtId: true,
startDate: true,
tenureType: true,
status: true,
},
});
const portalEnabled = await isPortalEnabledForPort(portId);
return {
...client,
contacts,
tags: clientTagRows.map((r) => r.tag),
yachts: yachtRows,
companies: membershipRows,
activeReservations,
clientPortalEnabled: portalEnabled,
};
}
// ─── Create ───────────────────────────────────────────────────────────────────
export async function createClient(portId: string, data: CreateClientInput, meta: AuditMeta) {
const result = await withTransaction(async (tx) => {
const { contacts: contactsInput, tagIds, ...clientData } = data;
const [client] = await tx
.insert(clients)
.values({ portId, ...clientData })
.returning();
if (contactsInput.length > 0) {
await tx
.insert(clientContacts)
.values(contactsInput.map((c) => ({ clientId: client!.id, ...c })));
}
if (tagIds && tagIds.length > 0) {
await tx.insert(clientTags).values(tagIds.map((tagId) => ({ clientId: client!.id, tagId })));
}
return client!;
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'client',
entityId: result.id,
newValue: { fullName: result.fullName },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:created', {
clientId: result.id,
clientName: result.fullName ?? '',
source: result.source ?? '',
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:created', { clientId: result.id }),
);
return result;
}
// ─── Update ───────────────────────────────────────────────────────────────────
export async function updateClient(
id: string,
portId: string,
data: UpdateClientInput,
meta: AuditMeta,
) {
const existing = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Client');
}
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
const [updated] = await db
.update(clients)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(clients.id, id), eq(clients.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'client',
entityId: id,
oldValue: diff as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', {
clientId: id,
changedFields: Object.keys(diff),
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:updated', { clientId: id }),
);
return updated;
}
// ─── Archive / Restore ────────────────────────────────────────────────────────
export async function archiveClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Client');
}
await softDelete(clients, clients.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:archived', { clientId: id }),
);
}
export async function restoreClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Client');
}
await restore(clients, clients.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:restored', { clientId: id });
}
// ─── Contacts ─────────────────────────────────────────────────────────────────
export async function listContacts(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.clientContacts.findMany({
where: eq(clientContacts.clientId, clientId),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
}
export async function addContact(
clientId: string,
portId: string,
data: { channel: string; value: string; label?: string; isPrimary?: boolean; notes?: 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 [contact] = await db
.insert(clientContacts)
.values({ clientId, ...data })
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'clientContact',
entityId: contact!.id,
newValue: { clientId, channel: contact!.channel },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
return contact!;
}
export async function updateContact(
contactId: string,
clientId: string,
portId: string,
data: Partial<{
channel: string;
value: string;
label: string;
isPrimary: boolean;
notes: 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 contact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
});
if (!contact) throw new NotFoundError('Contact');
const [updated] = await db
.update(clientContacts)
.set({ ...data, updatedAt: new Date() })
.where(eq(clientContacts.id, contactId))
.returning();
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
return updated;
}
export async function removeContact(
contactId: 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 contact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
});
if (!contact) throw new NotFoundError('Contact');
await db.delete(clientContacts).where(eq(clientContacts.id, contactId));
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
}
// ─── Tags ─────────────────────────────────────────────────────────────────────
export async function setClientTags(
clientId: string,
portId: string,
tagIds: string[],
meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
await db.delete(clientTags).where(eq(clientTags.clientId, clientId));
if (tagIds.length > 0) {
await db.insert(clientTags).values(tagIds.map((tagId) => ({ clientId, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'client',
entityId: clientId,
newValue: { tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['tags'] });
}
// ─── Relationships ────────────────────────────────────────────────────────────
export async function listRelationships(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.clientRelationships.findMany({
where: (r, { or, eq }) => or(eq(r.clientAId, clientId), eq(r.clientBId, clientId)),
});
}
export async function createRelationship(
clientId: string,
portId: string,
data: { clientBId: string; relationshipType: string; description?: 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 [rel] = await db
.insert(clientRelationships)
.values({ portId, clientAId: clientId, ...data })
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'clientRelationship',
entityId: rel!.id,
newValue: { clientAId: clientId, clientBId: data.clientBId, type: data.relationshipType },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return rel!;
}
export async function deleteRelationship(
relId: string,
clientId: string,
portId: string,
meta: AuditMeta,
) {
const rel = await db.query.clientRelationships.findFirst({
where: eq(clientRelationships.id, relId),
});
if (!rel || rel.portId !== portId) throw new NotFoundError('Relationship');
await db.delete(clientRelationships).where(eq(clientRelationships.id, relId));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'clientRelationship',
entityId: relId,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
// ─── Find Duplicates ──────────────────────────────────────────────────────────
export async function findDuplicates(portId: string, fullName: string) {
return db.query.clients.findMany({
where: (c, { and, eq }) => and(eq(c.portId, portId), ilike(c.fullName, `%${fullName}%`)),
limit: 5,
});
}
// ─── Options (for comboboxes) ─────────────────────────────────────────────────
export async function listClientOptions(portId: string, search?: string) {
const conditions = [eq(clients.portId, portId)];
if (search) {
conditions.push(ilike(clients.fullName, `%${search}%`));
}
return db
.select({ id: clients.id, fullName: clients.fullName })
.from(clients)
.where(and(...conditions))
.orderBy(clients.fullName)
.limit(50);
}