Updated tenancy-auto-create integration test to assert M29 (explicit disable respected) instead of the old re-enable behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
605 lines
21 KiB
TypeScript
605 lines
21 KiB
TypeScript
import { and, eq, inArray } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
|
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';
|
|
import { sendEmail } from '@/lib/email';
|
|
import { assertValidStage } from '@/lib/services/residential-stages.service';
|
|
import { SETTING_KEYS, getPortBrandingConfig, readSetting } from '@/lib/services/port-config';
|
|
import { brandingPrimaryColor, renderShell } from '@/lib/email/shell';
|
|
|
|
// ─── 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 });
|
|
|
|
// Best-effort auto-link to an existing main-client record. Match by
|
|
// email (cheap, single index lookup) then by E.164 phone (next-best).
|
|
// Failures or no-match scenarios silently leave the row unlinked -
|
|
// reps can wire it up via the admin UI later.
|
|
void findAndLinkMatchingMainClient(row.id, portId).catch((err) => {
|
|
console.error('[residential] auto-link match failed', err);
|
|
});
|
|
|
|
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,
|
|
source,
|
|
assignedTo,
|
|
residentialClientId,
|
|
} = query;
|
|
|
|
const filters = [];
|
|
// Normalize string-or-array filter inputs into Drizzle `inArray` clauses
|
|
// when multiple values are supplied; fall back to `eq` for the single
|
|
// case so the validator's union shape doesn't change the SQL.
|
|
if (pipelineStage) {
|
|
const values = Array.isArray(pipelineStage) ? pipelineStage : [pipelineStage];
|
|
if (values.length > 1) filters.push(inArray(residentialInterests.pipelineStage, values));
|
|
else if (values[0]) filters.push(eq(residentialInterests.pipelineStage, values[0]));
|
|
}
|
|
if (source) {
|
|
const values = Array.isArray(source) ? source : [source];
|
|
if (values.length > 1) filters.push(inArray(residentialInterests.source, values));
|
|
else if (values[0]) filters.push(eq(residentialInterests.source, values[0]));
|
|
}
|
|
if (assignedTo) filters.push(eq(residentialInterests.assignedTo, assignedTo));
|
|
if (residentialClientId)
|
|
filters.push(eq(residentialInterests.residentialClientId, residentialClientId));
|
|
|
|
const result = await 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,
|
|
});
|
|
|
|
// Per-page client-name lookup so the list table can render the
|
|
// residentialClient.fullName in column 1 without a second hop per row.
|
|
// Two-pass post-fetch pattern mirrors the main interests list (latest
|
|
// stage / tag aggregation lives there). Page size is capped by the
|
|
// validator so this stays a single bulk IN-list query.
|
|
// `buildListQuery` returns `data: typeof table.$inferSelect[]` so the
|
|
// row type is `residentialInterests` - known shape, but TS infers
|
|
// `unknown[]` through the generic helper. Cast through `unknown` once
|
|
// here so the downstream enrichment is type-clean.
|
|
type InterestRow = typeof residentialInterests.$inferSelect;
|
|
const typedData = result.data as unknown as InterestRow[];
|
|
if (typedData.length > 0) {
|
|
const clientIds = Array.from(
|
|
new Set(typedData.map((r) => r.residentialClientId).filter((v): v is string => Boolean(v))),
|
|
);
|
|
if (clientIds.length > 0) {
|
|
const clients = await db
|
|
.select({
|
|
id: residentialClients.id,
|
|
fullName: residentialClients.fullName,
|
|
})
|
|
.from(residentialClients)
|
|
.where(
|
|
and(eq(residentialClients.portId, portId), inArray(residentialClients.id, clientIds)),
|
|
);
|
|
const nameById = new Map(clients.map((c) => [c.id, c.fullName]));
|
|
result.data = typedData.map((r) => ({
|
|
...r,
|
|
clientName: nameById.get(r.residentialClientId) ?? null,
|
|
})) as unknown as typeof result.data;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
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 });
|
|
|
|
// Fire-and-forget partner-forward email. Failures here MUST NOT block
|
|
// the create - partner notification is a courtesy. Errors are logged
|
|
// server-side so the operator can see them, but the API still 201s.
|
|
void forwardResidentialInquiryToPartner({
|
|
portId,
|
|
client,
|
|
interest: row,
|
|
}).catch((err) => {
|
|
console.error('[residential] partner forward failed', err);
|
|
});
|
|
|
|
return row;
|
|
}
|
|
|
|
/**
|
|
* Sends a courtesy notification to the configured partner email
|
|
* recipients when a new residential inquiry lands. Recipients are
|
|
* configured per-port via the `residential_partner_recipients` system
|
|
* setting (comma-separated list). No-ops when the setting is blank or
|
|
* the inquiry has no usable client snapshot.
|
|
*
|
|
* Uses the same branded shell as the rest of the transactional emails
|
|
* so the partner sees a port-branded notification rather than a raw
|
|
* HTML block.
|
|
*/
|
|
async function forwardResidentialInquiryToPartner(input: {
|
|
portId: string;
|
|
client: typeof residentialClients.$inferSelect;
|
|
interest: typeof residentialInterests.$inferSelect;
|
|
}): Promise<void> {
|
|
const { portId, client, interest } = input;
|
|
const raw = await readSetting<string>(SETTING_KEYS.residentialPartnerRecipients, portId);
|
|
if (!raw) return;
|
|
const recipients = raw
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter((s) => /^.+@.+\..+$/.test(s));
|
|
if (recipients.length === 0) return;
|
|
|
|
const branding = await getPortBrandingConfig(portId);
|
|
const accent = brandingPrimaryColor({
|
|
logoUrl: branding.logoUrl,
|
|
backgroundUrl: branding.emailBackgroundUrl,
|
|
primaryColor: branding.primaryColor,
|
|
emailHeaderHtml: branding.emailHeaderHtml,
|
|
emailFooterHtml: branding.emailFooterHtml,
|
|
});
|
|
const escapeHtml = (s: string): string =>
|
|
s
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
const subject = `New residential inquiry: ${client.fullName}`;
|
|
const body = `
|
|
<h1 style="font-family:Arial,sans-serif;font-size:20px;font-weight:600;color:#0f172a;margin:0 0 12px;">
|
|
New residential inquiry
|
|
</h1>
|
|
<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.55;color:#334155;margin:0 0 12px;">
|
|
A new residential inquiry was submitted via the CRM. Details below; full record lives in the
|
|
port's CRM under Residential > Interests.
|
|
</p>
|
|
<table role="presentation" style="font-family:Arial,sans-serif;font-size:13px;color:#334155;border-collapse:collapse;margin:8px 0 16px;">
|
|
<tr>
|
|
<td style="padding:4px 12px 4px 0;color:#64748b;">Name</td>
|
|
<td style="padding:4px 0;">${escapeHtml(client.fullName)}</td>
|
|
</tr>
|
|
${
|
|
client.email
|
|
? `<tr>
|
|
<td style="padding:4px 12px 4px 0;color:#64748b;">Email</td>
|
|
<td style="padding:4px 0;">${escapeHtml(client.email)}</td>
|
|
</tr>`
|
|
: ''
|
|
}
|
|
${
|
|
client.phone
|
|
? `<tr>
|
|
<td style="padding:4px 12px 4px 0;color:#64748b;">Phone</td>
|
|
<td style="padding:4px 0;">${escapeHtml(client.phone)}</td>
|
|
</tr>`
|
|
: ''
|
|
}
|
|
${
|
|
client.placeOfResidence
|
|
? `<tr>
|
|
<td style="padding:4px 12px 4px 0;color:#64748b;">Location</td>
|
|
<td style="padding:4px 0;">${escapeHtml(client.placeOfResidence)}</td>
|
|
</tr>`
|
|
: ''
|
|
}
|
|
${
|
|
interest.preferences
|
|
? `<tr>
|
|
<td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Preferences</td>
|
|
<td style="padding:4px 0;white-space:pre-wrap;">${escapeHtml(interest.preferences)}</td>
|
|
</tr>`
|
|
: ''
|
|
}
|
|
${
|
|
interest.notes
|
|
? `<tr>
|
|
<td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Notes</td>
|
|
<td style="padding:4px 0;white-space:pre-wrap;">${escapeHtml(interest.notes)}</td>
|
|
</tr>`
|
|
: ''
|
|
}
|
|
</table>
|
|
<p style="font-family:Arial,sans-serif;font-size:12px;color:#64748b;margin:0;">
|
|
Pipeline stage at submission: <span style="color:${accent};font-weight:600;">${escapeHtml(interest.pipelineStage)}</span>.
|
|
</p>
|
|
`;
|
|
const html = renderShell({
|
|
title: subject,
|
|
body,
|
|
branding: {
|
|
logoUrl: branding.logoUrl,
|
|
backgroundUrl: branding.emailBackgroundUrl,
|
|
primaryColor: branding.primaryColor,
|
|
emailHeaderHtml: branding.emailHeaderHtml,
|
|
emailFooterHtml: branding.emailFooterHtml,
|
|
},
|
|
});
|
|
await sendEmail(recipients, subject, html, undefined, undefined, portId);
|
|
}
|
|
|
|
/**
|
|
* Best-effort matcher that links a residential client to an existing
|
|
* main `clients` row representing the same person. Matches by:
|
|
* 1. email (residential.email matches clients.contacts of channel='email')
|
|
* 2. phoneE164 (residential.phone_e164 matches clients.contacts of
|
|
* channel='phone' or 'whatsapp')
|
|
*
|
|
* Match ordering is "exact email beats phone beats nothing" - the
|
|
* first hit wins. Returns the linked main-client id when a match was
|
|
* found, or null when no match exists.
|
|
*
|
|
* Caller is expected to handle errors / call best-effort (residential
|
|
* lifecycle MUST NOT block on matching). Exported so the admin
|
|
* backfill script can re-run the matcher across historical rows.
|
|
*/
|
|
export async function findAndLinkMatchingMainClient(
|
|
residentialClientId: string,
|
|
portId: string,
|
|
): Promise<string | null> {
|
|
const row = await db.query.residentialClients.findFirst({
|
|
where: and(
|
|
eq(residentialClients.id, residentialClientId),
|
|
eq(residentialClients.portId, portId),
|
|
),
|
|
});
|
|
if (!row) return null;
|
|
if (row.linkedClientId) return row.linkedClientId;
|
|
|
|
// Try email match first. Look for a main-client contact row in the
|
|
// same port whose value matches case-insensitively. Pick the most-
|
|
// recently-updated client when multiple match (rare but possible).
|
|
let matchedClientId: string | null = null;
|
|
if (row.email) {
|
|
const emailMatches = await db
|
|
.select({ clientId: clientContacts.clientId })
|
|
.from(clientContacts)
|
|
.innerJoin(clients, eq(clients.id, clientContacts.clientId))
|
|
.where(
|
|
and(
|
|
eq(clients.portId, portId),
|
|
eq(clientContacts.channel, 'email'),
|
|
eq(clientContacts.value, row.email.toLowerCase()),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (emailMatches[0]) matchedClientId = emailMatches[0].clientId;
|
|
}
|
|
|
|
if (!matchedClientId && row.phoneE164) {
|
|
const phoneMatches = await db
|
|
.select({ clientId: clientContacts.clientId })
|
|
.from(clientContacts)
|
|
.innerJoin(clients, eq(clients.id, clientContacts.clientId))
|
|
.where(
|
|
and(
|
|
eq(clients.portId, portId),
|
|
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
|
eq(clientContacts.valueE164, row.phoneE164),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (phoneMatches[0]) matchedClientId = phoneMatches[0].clientId;
|
|
}
|
|
|
|
if (!matchedClientId) return null;
|
|
|
|
await db
|
|
.update(residentialClients)
|
|
.set({ linkedClientId: matchedClientId, updatedAt: new Date() })
|
|
.where(
|
|
and(eq(residentialClients.id, residentialClientId), eq(residentialClients.portId, portId)),
|
|
);
|
|
emitToRoom(`port:${portId}`, 'residential_client:updated', { id: residentialClientId });
|
|
return matchedClientId;
|
|
}
|
|
|
|
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');
|
|
|
|
// Reject moves to a stage that isn't in this port's live stage list.
|
|
// The validator accepts any string (so admins can add custom stages
|
|
// without a deploy); the membership check is enforced here at write
|
|
// time so a PATCH can't park an interest on a non-existent stage that
|
|
// would then surface as an orphan in funnel reports.
|
|
if (data.pipelineStage !== undefined) {
|
|
await assertValidStage(portId, data.pipelineStage);
|
|
}
|
|
|
|
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 });
|
|
}
|