diff --git a/src/app/api/public/residential-inquiries/route.ts b/src/app/api/public/residential-inquiries/route.ts index ee75126..fa2a96f 100644 --- a/src/app/api/public/residential-inquiries/route.ts +++ b/src/app/api/public/residential-inquiries/route.ts @@ -11,6 +11,7 @@ import { residentialClientConfirmation, residentialSalesAlert, } from '@/lib/email/templates/residential-inquiry'; +import { resolveSubject } from '@/lib/email/resolve-subject'; import { env } from '@/lib/env'; import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; @@ -145,7 +146,13 @@ async function sendResidentialNotifications(args: { firstName: data.firstName, contactEmail: 'sales@portnimara.com', }); - await sendEmail(data.email, confirmation.subject, confirmation.html); + const confirmationSubject = await resolveSubject({ + key: 'residential_inquiry_client_confirmation', + portId, + fallback: confirmation.subject, + tokens: { portName: 'Port Nimara', recipientName: data.firstName }, + }); + await sendEmail(data.email, confirmationSubject, confirmation.html); // Sales-team alert - pull recipients from system_settings if configured; // fall back to the inquiry_contact_email if available. @@ -184,6 +191,17 @@ async function sendResidentialNotifications(args: { preferences: data.preferences, crmDeepLink, }); + const alertSubject = await resolveSubject({ + key: 'residential_inquiry_sales_alert', + portId, + fallback: alert.subject, + tokens: { + portName: 'Port Nimara', + clientName: `${data.firstName} ${data.lastName}`.trim(), + email: data.email, + phone: data.phone, + }, + }); - await sendEmail(recipients, alert.subject, alert.html); + await sendEmail(recipients, alertSubject, alert.html); } diff --git a/src/lib/email/resolve-subject.ts b/src/lib/email/resolve-subject.ts new file mode 100644 index 0000000..b15f31c --- /dev/null +++ b/src/lib/email/resolve-subject.ts @@ -0,0 +1,34 @@ +/** + * Helper that turns a template key + per-port settings into a final + * subject line. Centralises the override-resolution + token-substitution + * pattern so every transactional email sender is one call away from + * honoring the admin's `email_template__subject` override. + * + * Wire-up (per send site): + * const subject = await resolveSubject({ + * key: 'crm_invite', + * portId, + * fallback: result.subject, + * tokens: { portName, recipientName: data.recipientName, ttlHours: 48 }, + * }); + * + * The override is read from `system_settings.email_template__subject`. + * If unset / empty / non-string, the `fallback` is returned as-is. + */ + +import { loadSubjectOverride, applySubjectTokens } from '@/lib/email/template-overrides'; +import type { TemplateKey } from '@/lib/email/template-catalog'; + +export async function resolveSubject(args: { + key: TemplateKey; + /** Optional — when omitted (e.g. system-level emails with no port + * context), only the fallback subject is returned. */ + portId?: string | null; + fallback: string; + tokens?: Record; +}): Promise { + if (!args.portId) return args.fallback; + const override = await loadSubjectOverride(args.portId, args.key); + if (!override) return args.fallback; + return args.tokens ? applySubjectTokens(override, args.tokens) : override; +} diff --git a/src/lib/queue/workers/email.ts b/src/lib/queue/workers/email.ts index 9911458..ede55ea 100644 --- a/src/lib/queue/workers/email.ts +++ b/src/lib/queue/workers/email.ts @@ -18,31 +18,48 @@ export const emailWorker = new Worker( break; } case 'send-inquiry-confirmation': { - const { to, firstName, mooringNumber, contactEmail } = job.data as { + const { to, firstName, mooringNumber, contactEmail, portId, portName } = job.data as { to: string; firstName: string; mooringNumber: string | null; contactEmail: string; + portId?: string; + portName?: string; }; const { inquiryClientConfirmation } = await import('@/lib/email/templates/inquiry-client-confirmation'); const { sendEmail } = await import('@/lib/email/index'); + const { resolveSubject } = await import('@/lib/email/resolve-subject'); const email = inquiryClientConfirmation({ firstName, mooringNumber, contactEmail }); - await sendEmail(to, email.subject, email.html, undefined, email.text); + const subject = await resolveSubject({ + key: 'inquiry_client_confirmation', + portId, + fallback: email.subject, + tokens: { + portName: portName ?? 'Port Nimara', + recipientName: firstName, + mooringNumber: mooringNumber ?? '', + }, + }); + await sendEmail(to, subject, email.html, undefined, email.text, portId); break; } case 'send-inquiry-sales-notification': { - const { to, fullName, email, phone, mooringNumber, crmUrl } = job.data as { - to: string; - fullName: string; - email: string; - phone: string; - mooringNumber: string | null; - crmUrl: string; - }; + const { to, fullName, email, phone, mooringNumber, crmUrl, portId, portName } = + job.data as { + to: string; + fullName: string; + email: string; + phone: string; + mooringNumber: string | null; + crmUrl: string; + portId?: string; + portName?: string; + }; const { inquirySalesNotification } = await import('@/lib/email/templates/inquiry-sales-notification'); const { sendEmail } = await import('@/lib/email/index'); + const { resolveSubject } = await import('@/lib/email/resolve-subject'); const notification = inquirySalesNotification({ fullName, email, @@ -50,7 +67,18 @@ export const emailWorker = new Worker( mooringNumber, crmUrl, }); - await sendEmail(to, notification.subject, notification.html, undefined, notification.text); + const subject = await resolveSubject({ + key: 'inquiry_sales_notification', + portId, + fallback: notification.subject, + tokens: { + portName: portName ?? 'Port Nimara', + clientName: fullName, + mooringNumber: mooringNumber ?? '', + email, + }, + }); + await sendEmail(to, subject, notification.html, undefined, notification.text, portId); break; } default: diff --git a/src/lib/services/crm-invite.service.ts b/src/lib/services/crm-invite.service.ts index b699198..ac6fcb7 100644 --- a/src/lib/services/crm-invite.service.ts +++ b/src/lib/services/crm-invite.service.ts @@ -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, diff --git a/src/lib/services/inquiry-notifications.service.ts b/src/lib/services/inquiry-notifications.service.ts index 0a8d6b4..c0827cd 100644 --- a/src/lib/services/inquiry-notifications.service.ts +++ b/src/lib/services/inquiry-notifications.service.ts @@ -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', }), ), );