fix(audit): wire 6 missing email subject overrides (R2-H14)

Admin-editable subject overrides at /admin/email-templates were no-ops
for 6 of 8 templates — only portal_activation and portal_reset called
loadSubjectOverride. Added a shared resolveSubject() helper and wired
it into the missing senders:

- crm_invite + portal_invite_resend (crm-invite.service.ts)
- inquiry_client_confirmation (email worker via portId on job payload)
- inquiry_sales_notification (email worker via portId on job payload)
- residential_inquiry_client_confirmation (residential-inquiries route)
- residential_inquiry_sales_alert (residential-inquiries route)

The inquiry email worker payloads now carry portId + portName so the
worker can resolve the per-port override; producers in inquiry-
notifications.service.ts pass them through.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 22:26:41 +02:00
parent 59b9e8f177
commit c312cd3685
5 changed files with 126 additions and 17 deletions

View File

@@ -9,6 +9,7 @@ import { userProfiles } from '@/lib/db/schema/users';
import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email';
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
import { resolveSubject } from '@/lib/email/resolve-subject';
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { hashToken, mintToken } from '@/lib/portal/passwords';
@@ -67,14 +68,26 @@ export async function createCrmInvite(args: {
});
const link = `${env.APP_URL}/set-password?token=${raw}`;
const { subject, html, text } = crmInviteEmail({
const result = crmInviteEmail({
link,
ttlHours: INVITE_TTL_HOURS,
recipientName: args.name,
isSuperAdmin,
});
// CRM invites are global (no portId at create-invite time). The
// override resolver returns the fallback when portId is null.
const subject = await resolveSubject({
key: 'crm_invite',
portId: null,
fallback: result.subject,
tokens: {
portName: 'Port Nimara',
recipientName: args.name ?? '',
ttlHours: INVITE_TTL_HOURS,
},
});
await sendEmail(email, subject, html, undefined, text);
await sendEmail(email, subject, result.html, undefined, result.text);
return { inviteId: row.id, link };
}
@@ -217,13 +230,25 @@ export async function resendCrmInvite(
.where(eq(crmUserInvites.id, inviteId));
const link = `${env.APP_URL}/set-password?token=${raw}`;
const { subject, html, text } = crmInviteEmail({
const result = crmInviteEmail({
link,
ttlHours: INVITE_TTL_HOURS,
recipientName: invite.name ?? undefined,
isSuperAdmin: invite.isSuperAdmin,
});
await sendEmail(invite.email, subject, html, undefined, text);
// Resend uses the dedicated portal_invite_resend key so admins can
// word the resend differently from the original.
const subject = await resolveSubject({
key: 'portal_invite_resend',
portId: meta.portId ?? null,
fallback: result.subject,
tokens: {
portName: 'Port Nimara',
recipientName: invite.name ?? '',
ttlHours: INVITE_TTL_HOURS,
},
});
await sendEmail(invite.email, subject, result.html, undefined, result.text);
void createAuditLog({
userId: meta.userId,

View File

@@ -53,6 +53,8 @@ export async function sendInquiryNotifications(params: InquiryNotificationParams
firstName,
mooringNumber,
contactEmail,
portId,
portName: 'Port Nimara', // future: resolve from getPortBrandingConfig
});
} catch (err) {
logger.error({ err, interestId }, 'Failed to queue client confirmation email');
@@ -120,6 +122,8 @@ export async function sendInquiryNotifications(params: InquiryNotificationParams
phone: clientPhone,
mooringNumber,
crmUrl,
portId,
portName: 'Port Nimara',
}),
),
);