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>
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
import { and, desc, eq, inArray, isNull, ne } from 'drizzle-orm';
|
|
import { db } from '@/lib/db';
|
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
|
import type { CompanyMembership } from '@/lib/db/schema/companies';
|
|
import { clients } from '@/lib/db/schema/clients';
|
|
import { withTransaction } from '@/lib/db/utils';
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
|
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
import { diffEntity } from '@/lib/entity-diff';
|
|
import type {
|
|
AddMembershipInput,
|
|
UpdateMembershipInput,
|
|
EndMembershipInput,
|
|
} from '@/lib/validators/company-memberships';
|
|
|
|
export type { CompanyMembership };
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns true if the error is a Postgres unique-violation (SQLSTATE 23505).
|
|
*/
|
|
function isUniqueViolation(err: unknown): boolean {
|
|
if (!err || typeof err !== 'object') return false;
|
|
const e = err as { code?: unknown; cause?: { code?: unknown } };
|
|
if (e.code === '23505') return true;
|
|
if (e.cause && typeof e.cause === 'object' && e.cause.code === '23505') return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Loads a membership row and verifies the joined company belongs to `portId`.
|
|
* Throws NotFoundError('Membership') if the row is missing or cross-tenant.
|
|
*
|
|
* Uses a JOIN to companies (memberships have no portId column - they inherit
|
|
* tenancy via the parent company).
|
|
*/
|
|
async function loadMembershipScoped(
|
|
membershipId: string,
|
|
portId: string,
|
|
): Promise<CompanyMembership> {
|
|
const rows = await db
|
|
.select({ membership: companyMemberships })
|
|
.from(companyMemberships)
|
|
.innerJoin(companies, eq(companies.id, companyMemberships.companyId))
|
|
.where(and(eq(companyMemberships.id, membershipId), eq(companies.portId, portId)))
|
|
.limit(1);
|
|
|
|
const row = rows[0];
|
|
if (!row) throw new NotFoundError('Membership');
|
|
return row.membership;
|
|
}
|
|
|
|
// ─── Add ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function addMembership(
|
|
companyId: string,
|
|
portId: string,
|
|
data: AddMembershipInput,
|
|
meta: AuditMeta,
|
|
): Promise<CompanyMembership> {
|
|
// Verify the company exists in this port.
|
|
const company = await db.query.companies.findFirst({
|
|
where: and(eq(companies.id, companyId), eq(companies.portId, portId)),
|
|
});
|
|
if (!company) throw new ValidationError('company not found');
|
|
|
|
// Verify the client exists in this port.
|
|
const client = await db.query.clients.findFirst({
|
|
where: and(eq(clients.id, data.clientId), eq(clients.portId, portId)),
|
|
});
|
|
if (!client) throw new ValidationError('client not found');
|
|
|
|
try {
|
|
const [membership] = await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId,
|
|
clientId: data.clientId,
|
|
role: data.role,
|
|
roleDetail: data.roleDetail ?? null,
|
|
startDate: data.startDate,
|
|
isPrimary: data.isPrimary ?? false,
|
|
notes: data.notes ?? null,
|
|
})
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'create',
|
|
entityType: 'company_membership',
|
|
entityId: membership!.id,
|
|
newValue: {
|
|
companyId: membership!.companyId,
|
|
clientId: membership!.clientId,
|
|
role: membership!.role,
|
|
startDate: membership!.startDate,
|
|
},
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'company_membership:added', {
|
|
membershipId: membership!.id,
|
|
companyId: membership!.companyId,
|
|
clientId: membership!.clientId,
|
|
});
|
|
|
|
return membership!;
|
|
} catch (err) {
|
|
if (isUniqueViolation(err)) {
|
|
throw new ConflictError('membership already exists');
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ─── Update ──────────────────────────────────────────────────────────────────
|
|
|
|
export async function updateMembership(
|
|
membershipId: string,
|
|
portId: string,
|
|
data: UpdateMembershipInput,
|
|
meta: AuditMeta,
|
|
): Promise<CompanyMembership> {
|
|
const existing = await loadMembershipScoped(membershipId, portId);
|
|
|
|
const { diff } = diffEntity(
|
|
existing as unknown as Record<string, unknown>,
|
|
data as Record<string, unknown>,
|
|
);
|
|
|
|
const rows = await db
|
|
.update(companyMemberships)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
// Defense-in-depth: companyMemberships has no port_id column, so the
|
|
// tenant boundary lives on the parent company. Pin the WHERE to a
|
|
// sub-select of port companies — even if loadMembershipScoped were
|
|
// ever bypassed, a stray membershipId from another tenant cannot
|
|
// mutate.
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.id, membershipId),
|
|
inArray(
|
|
companyMemberships.companyId,
|
|
db.select({ id: companies.id }).from(companies).where(eq(companies.portId, portId)),
|
|
),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
const updated = rows[0]!;
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'company_membership',
|
|
entityId: membershipId,
|
|
oldValue: diff as Record<string, unknown>,
|
|
newValue: data as Record<string, unknown>,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'company_membership:updated', {
|
|
membershipId,
|
|
changedFields: Object.keys(diff),
|
|
});
|
|
|
|
return updated;
|
|
}
|
|
|
|
// ─── End (set endDate) ───────────────────────────────────────────────────────
|
|
|
|
export async function endMembership(
|
|
membershipId: string,
|
|
portId: string,
|
|
data: EndMembershipInput,
|
|
meta: AuditMeta,
|
|
): Promise<CompanyMembership> {
|
|
const existing = await loadMembershipScoped(membershipId, portId);
|
|
|
|
const rows = await db
|
|
.update(companyMemberships)
|
|
.set({ endDate: data.endDate, updatedAt: new Date() })
|
|
// Defense-in-depth: companyMemberships has no port_id column, so the
|
|
// tenant boundary lives on the parent company. Pin the WHERE to a
|
|
// sub-select of port companies — even if loadMembershipScoped were
|
|
// ever bypassed, a stray membershipId from another tenant cannot
|
|
// mutate.
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.id, membershipId),
|
|
inArray(
|
|
companyMemberships.companyId,
|
|
db.select({ id: companies.id }).from(companies).where(eq(companies.portId, portId)),
|
|
),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
const updated = rows[0]!;
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'company_membership',
|
|
entityId: membershipId,
|
|
oldValue: { endDate: existing.endDate },
|
|
newValue: { endDate: updated.endDate },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'company_membership:ended', {
|
|
membershipId,
|
|
companyId: updated.companyId,
|
|
clientId: updated.clientId,
|
|
});
|
|
|
|
return updated;
|
|
}
|
|
|
|
// ─── Set Primary (atomic) ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Marks this membership as primary for its company, un-primary-ing any other
|
|
* memberships of the same company atomically.
|
|
*/
|
|
export async function setPrimary(
|
|
membershipId: string,
|
|
portId: string,
|
|
meta: AuditMeta,
|
|
): Promise<CompanyMembership> {
|
|
// Tenant-scoped load (outside tx is fine - we re-read inside).
|
|
const existing = await loadMembershipScoped(membershipId, portId);
|
|
|
|
return await withTransaction(async (tx) => {
|
|
await tx
|
|
.update(companyMemberships)
|
|
.set({ isPrimary: false, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, existing.companyId),
|
|
ne(companyMemberships.id, membershipId),
|
|
),
|
|
);
|
|
|
|
const rows = await tx
|
|
.update(companyMemberships)
|
|
.set({ isPrimary: true, updatedAt: new Date() })
|
|
// Same defense-in-depth via port-companies sub-select — see updateMembership.
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.id, membershipId),
|
|
inArray(
|
|
companyMemberships.companyId,
|
|
tx.select({ id: companies.id }).from(companies).where(eq(companies.portId, portId)),
|
|
),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
const updated = rows[0]!;
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'company_membership',
|
|
entityId: membershipId,
|
|
oldValue: { isPrimary: existing.isPrimary },
|
|
newValue: { isPrimary: true },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'company_membership:updated', {
|
|
membershipId,
|
|
changedFields: ['isPrimary'],
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
// ─── List by Company ─────────────────────────────────────────────────────────
|
|
|
|
export async function listByCompany(
|
|
companyId: string,
|
|
portId: string,
|
|
opts?: { activeOnly?: boolean },
|
|
): Promise<CompanyMembership[]> {
|
|
const activeOnly = opts?.activeOnly ?? true;
|
|
|
|
// Verify the company belongs to the port (prevents cross-tenant enumeration).
|
|
const company = await db.query.companies.findFirst({
|
|
where: and(eq(companies.id, companyId), eq(companies.portId, portId)),
|
|
});
|
|
if (!company) throw new NotFoundError('Company');
|
|
|
|
const conditions = [eq(companyMemberships.companyId, companyId)];
|
|
if (activeOnly) conditions.push(isNull(companyMemberships.endDate));
|
|
|
|
return await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(companyMemberships.isPrimary), desc(companyMemberships.startDate));
|
|
}
|
|
|
|
// ─── List by Client ──────────────────────────────────────────────────────────
|
|
|
|
export async function listByClient(
|
|
clientId: string,
|
|
portId: string,
|
|
opts?: { activeOnly?: boolean },
|
|
): Promise<CompanyMembership[]> {
|
|
const activeOnly = opts?.activeOnly ?? true;
|
|
|
|
// Verify the client belongs to the port.
|
|
const client = await db.query.clients.findFirst({
|
|
where: and(eq(clients.id, clientId), eq(clients.portId, portId)),
|
|
});
|
|
if (!client) throw new NotFoundError('Client');
|
|
|
|
const conditions = [eq(companyMemberships.clientId, clientId)];
|
|
if (activeOnly) conditions.push(isNull(companyMemberships.endDate));
|
|
|
|
return await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(companyMemberships.isPrimary), desc(companyMemberships.startDate));
|
|
}
|