Files
pn-new-crm/src/lib/services/residential.service.ts
Matt Ciaccio 4eea4ceff9 fix(audit-tier-4): tenant-isolation defense-in-depth
Closes the audit's HIGH §10 + MED §§17–22 isolation footguns. None of
these are user-impactful TODAY — every site is preceded by a port-
scoped read or pre-validated by ctx.portId — but each is a future-
refactor accident waiting to happen, so the SQL itself now pins the
tenant boundary:

* mergeClients gains a callerPortId option; the route caller passes
  ctx.portId.  removeInterestBerth now requires portId and verifies
  both the interest and the berth share it before deleting the
  junction row.  All three callers updated.
* Six service mutations now scope the WHERE to (id, portId):
  form-templates update + delete, invoices.detectOverdue per-row
  update, notifications.markRead, clients.deleteRelationship.
  company-memberships uses an inArray sub-select against port
  companies (no port_id column on the table itself), covering
  updateMembership / endMembership / setPrimary.
* Port-scoped file lookups in portal.getDocumentDownloadUrl,
  reports.getDownloadUrl (file presign), berth-reservations.activate
  (contractFileId attach guard), and residential.getResidentialInterestById
  (residentialClient join).

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §10 + MED §§17–22
(auditor-B3 Issues 1–5,7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:48:13 +02:00

334 lines
10 KiB
TypeScript

import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { CodedError, NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore } from '@/lib/db/utils';
import type {
CreateResidentialClientInput,
CreateResidentialInterestInput,
ListResidentialClientsInput,
ListResidentialInterestsInput,
UpdateResidentialClientInput,
UpdateResidentialInterestInput,
} from '@/lib/validators/residential';
// ─── Residential clients ─────────────────────────────────────────────────────
export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) {
const { page, limit, sort, order, search, includeArchived, status, source } = query;
const filters = [];
if (status) filters.push(eq(residentialClients.status, status));
if (source) filters.push(eq(residentialClients.source, source));
return buildListQuery({
table: residentialClients,
portIdColumn: residentialClients.portId,
portId,
idColumn: residentialClients.id,
updatedAtColumn: residentialClients.updatedAt,
filters,
sort: sort
? {
column:
(residentialClients[sort as keyof typeof residentialClients] as never) ??
residentialClients.updatedAt,
direction: order ?? 'desc',
}
: undefined,
page,
pageSize: limit,
searchColumns: [
residentialClients.fullName,
residentialClients.email,
residentialClients.phone,
residentialClients.placeOfResidence,
],
searchTerm: search,
includeArchived,
archivedAtColumn: residentialClients.archivedAt,
});
}
export async function getResidentialClientById(id: string, portId: string) {
const client = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!client) throw new NotFoundError('Residential client');
const interests = await db.query.residentialInterests.findMany({
where: eq(residentialInterests.residentialClientId, id),
orderBy: (t, { desc }) => [desc(t.updatedAt)],
});
return { ...client, interests };
}
export async function createResidentialClient(
portId: string,
data: CreateResidentialClientInput,
meta: AuditMeta,
) {
const [row] = await db
.insert(residentialClients)
.values({ portId, ...data })
.returning();
if (!row)
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'Failed to create residential client',
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'residential_client',
entityId: row.id,
newValue: { fullName: row.fullName, email: row.email ?? undefined },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:created', { id: row.id });
return row;
}
export async function updateResidentialClient(
id: string,
portId: string,
data: UpdateResidentialClientInput,
meta: AuditMeta,
) {
const before = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!before) throw new NotFoundError('Residential client');
const [updated] = await db
.update(residentialClients)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Residential client');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'residential_client',
entityId: id,
oldValue: diffEntity(before, updated) as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:updated', { id });
return updated;
}
export async function archiveResidentialClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!existing) throw new NotFoundError('Residential client');
await softDelete(residentialClients, residentialClients.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'residential_client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:archived', { id });
}
export async function restoreResidentialClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!existing) throw new NotFoundError('Residential client');
await restore(residentialClients, residentialClients.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'residential_client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:restored', { id });
}
// ─── Residential interests ───────────────────────────────────────────────────
export async function listResidentialInterests(
portId: string,
query: ListResidentialInterestsInput,
) {
const {
page,
limit,
sort,
order,
search,
includeArchived,
pipelineStage,
assignedTo,
residentialClientId,
} = query;
const filters = [];
if (pipelineStage) filters.push(eq(residentialInterests.pipelineStage, pipelineStage));
if (assignedTo) filters.push(eq(residentialInterests.assignedTo, assignedTo));
if (residentialClientId)
filters.push(eq(residentialInterests.residentialClientId, residentialClientId));
return buildListQuery({
table: residentialInterests,
portIdColumn: residentialInterests.portId,
portId,
idColumn: residentialInterests.id,
updatedAtColumn: residentialInterests.updatedAt,
filters,
sort: sort
? {
column:
(residentialInterests[sort as keyof typeof residentialInterests] as never) ??
residentialInterests.updatedAt,
direction: order ?? 'desc',
}
: undefined,
page,
pageSize: limit,
searchColumns: [residentialInterests.notes, residentialInterests.preferences],
searchTerm: search,
includeArchived,
archivedAtColumn: residentialInterests.archivedAt,
});
}
export async function getResidentialInterestById(id: string, portId: string) {
const interest = await db.query.residentialInterests.findFirst({
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
});
if (!interest) throw new NotFoundError('Residential interest');
// The residentialInterest is already port-scoped; pin the client read
// to the same port too so a future drift (a foreign-port residential
// client id ever landing on the interest) cannot leak.
const client = await db.query.residentialClients.findFirst({
where: and(
eq(residentialClients.id, interest.residentialClientId),
eq(residentialClients.portId, portId),
),
});
return { ...interest, client };
}
export async function createResidentialInterest(
portId: string,
data: CreateResidentialInterestInput,
meta: AuditMeta,
) {
// Validate the residential client belongs to this port - prevents
// cross-port linking.
const client = await db.query.residentialClients.findFirst({
where: and(
eq(residentialClients.id, data.residentialClientId),
eq(residentialClients.portId, portId),
),
});
if (!client) throw new NotFoundError('Residential client');
const [row] = await db
.insert(residentialInterests)
.values({ portId, ...data })
.returning();
if (!row)
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'Failed to create residential interest',
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'residential_interest',
entityId: row.id,
newValue: { residentialClientId: row.residentialClientId, pipelineStage: row.pipelineStage },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: row.id });
return row;
}
export async function updateResidentialInterest(
id: string,
portId: string,
data: UpdateResidentialInterestInput,
meta: AuditMeta,
) {
const before = await db.query.residentialInterests.findFirst({
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
});
if (!before) throw new NotFoundError('Residential interest');
const [updated] = await db
.update(residentialInterests)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Residential interest');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'residential_interest',
entityId: id,
oldValue: diffEntity(before, updated) as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_interest:updated', { id });
return updated;
}
export async function archiveResidentialInterest(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.residentialInterests.findFirst({
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
});
if (!existing) throw new NotFoundError('Residential interest');
await softDelete(residentialInterests, residentialInterests.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'residential_interest',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_interest:archived', { id });
}