Files
pn-new-crm/src/lib/services/form-templates.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

127 lines
3.2 KiB
TypeScript

import { and, desc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { formTemplates } from '@/lib/db/schema/documents';
import { createAuditLog } from '@/lib/audit';
import { CodedError, NotFoundError } from '@/lib/errors';
import type {
CreateFormTemplateInput,
UpdateFormTemplateInput,
} from '@/lib/validators/form-templates';
interface AuditMeta {
userId: string;
portId: string;
ipAddress?: string;
userAgent?: string;
}
export async function listFormTemplates(portId: string) {
return db
.select()
.from(formTemplates)
.where(eq(formTemplates.portId, portId))
.orderBy(desc(formTemplates.updatedAt));
}
export async function getFormTemplateById(id: string, portId: string) {
const tpl = await db.query.formTemplates.findFirst({
where: and(eq(formTemplates.id, id), eq(formTemplates.portId, portId)),
});
if (!tpl) throw new NotFoundError('Form template');
return tpl;
}
export async function createFormTemplate(
portId: string,
data: CreateFormTemplateInput,
meta: AuditMeta,
) {
const [tpl] = await db
.insert(formTemplates)
.values({
portId,
name: data.name,
description: data.description ?? null,
fields: data.fields,
branding: data.branding ?? {},
isActive: data.isActive ?? true,
createdBy: meta.userId,
})
.returning();
if (!tpl)
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'Form template insert returned no row',
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'form_template',
entityId: tpl.id,
newValue: { name: data.name },
ipAddress: meta.ipAddress ?? '',
userAgent: meta.userAgent ?? '',
});
return tpl;
}
export async function updateFormTemplate(
id: string,
portId: string,
data: UpdateFormTemplateInput,
meta: AuditMeta,
) {
const existing = await getFormTemplateById(id, portId);
const [updated] = await db
.update(formTemplates)
.set({
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description ?? null }),
...(data.fields !== undefined && { fields: data.fields }),
...(data.branding !== undefined && { branding: data.branding }),
...(data.isActive !== undefined && { isActive: data.isActive }),
updatedAt: new Date(),
})
.where(and(eq(formTemplates.id, id), eq(formTemplates.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Form template');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'form_template',
entityId: id,
oldValue: { name: existing.name },
newValue: data,
ipAddress: meta.ipAddress ?? '',
userAgent: meta.userAgent ?? '',
});
return updated;
}
export async function deleteFormTemplate(id: string, portId: string, meta: AuditMeta) {
await getFormTemplateById(id, portId);
await db
.delete(formTemplates)
.where(and(eq(formTemplates.id, id), eq(formTemplates.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'form_template',
entityId: id,
ipAddress: meta.ipAddress ?? '',
userAgent: meta.userAgent ?? '',
});
}