diff --git a/src/app/api/public/website-inquiries/route.ts b/src/app/api/public/website-inquiries/route.ts index ef54910c..d94fe0a4 100644 --- a/src/app/api/public/website-inquiries/route.ts +++ b/src/app/api/public/website-inquiries/route.ts @@ -16,6 +16,11 @@ import { } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; +import { + isWebsiteIntakeEmailEnabled, + notifyWebsiteSubmissionInApp, + sendWebsiteSubmissionEmails, +} from '@/lib/services/website-intake-email.service'; /** * POST /api/public/website-inquiries @@ -169,6 +174,40 @@ export async function POST(req: NextRequest) { }, 'website inquiry captured', ); + + // In-app (bell) notifications for reps - always on a fresh capture, + // independent of email ownership, so inquiries surface in the CRM inbox. + void notifyWebsiteSubmissionInApp({ + portId: port.id, + portSlug: parsed.port_slug, + kind: parsed.kind, + submissionId: parsed.submission_id, + payload: parsed.payload, + }).catch((err) => + logger.error( + { err, submissionId: parsed.submission_id }, + 'Failed to create website-intake notifications', + ), + ); + + // Flag-gated CRM-owned emails (registrant confirmation + staff alert). + // Fire only on this fresh-insert branch so a redelivery never re-sends. + // Inline fire-and-forget: a send failure must not 500 the capture POST. + if (await isWebsiteIntakeEmailEnabled(port.id)) { + void sendWebsiteSubmissionEmails({ + portId: port.id, + portSlug: parsed.port_slug, + kind: parsed.kind, + submissionId: parsed.submission_id, + payload: parsed.payload, + }).catch((err) => + logger.error( + { err, submissionId: parsed.submission_id }, + 'Failed to send website-intake emails', + ), + ); + } + // L34 carve-out: deliberate bespoke `{ id, deduped }` shape (NOT the // `{ data }` envelope). This is the public website's intake contract — // the external marketing site reads `id`/`deduped` off the JSON root. diff --git a/src/lib/email/template-catalog.ts b/src/lib/email/template-catalog.ts index d5c7d6d9..c8962ad9 100644 --- a/src/lib/email/template-catalog.ts +++ b/src/lib/email/template-catalog.ts @@ -22,6 +22,8 @@ export const TEMPLATE_KEYS = [ 'inquiry_sales_notification', 'residential_inquiry_client_confirmation', 'residential_inquiry_sales_alert', + 'contact_form_sales_alert', + 'contact_form_client_confirmation', // M-EM04: daily notification digest. The digest service previously // resolved its subject via `'crm_invite' as any` because no entry // existed; making it a first-class key removes the cast and lets @@ -101,6 +103,20 @@ export const TEMPLATE_CATALOG: Record = { mergeTokens: ['portName', 'clientName', 'email', 'phone'], defaultSubject: 'New residential inquiry - {{clientName}}', }, + contact_form_sales_alert: { + key: 'contact_form_sales_alert', + label: 'Contact form - sales alert', + description: 'Internal alert sent to the sales team when a website contact form is submitted.', + mergeTokens: ['portName', 'clientName', 'email'], + defaultSubject: 'New contact form submission - {{clientName}}', + }, + contact_form_client_confirmation: { + key: 'contact_form_client_confirmation', + label: 'Contact form - client confirmation', + description: 'Auto-reply sent to a visitor after they submit the general website contact form.', + mergeTokens: ['portName', 'recipientName'], + defaultSubject: 'Thank you for contacting {{portName}}', + }, notification_digest: { key: 'notification_digest', label: 'Notification digest', diff --git a/src/lib/email/templates/contact-form-alert.tsx b/src/lib/email/templates/contact-form-alert.tsx new file mode 100644 index 00000000..ec12ba40 --- /dev/null +++ b/src/lib/email/templates/contact-form-alert.tsx @@ -0,0 +1,104 @@ +import { Button, Text, render } from '@react-email/components'; +import * as React from 'react'; + +import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; + +interface RenderOpts { + branding?: BrandingShell | null; + subject?: string | null; +} + +export interface ContactFormSalesAlertData { + fullName: string; + email: string; + interestType?: string | null; + comments?: string | null; + crmDeepLink?: string; + portName?: string; +} + +function SalesAlertBody({ + portName, + data, + accent, +}: { + portName: string; + data: ContactFormSalesAlertData; + accent: string; +}) { + const labelCell = { color: '#666', width: '140px' } as const; + return ( + <> + + New contact form submission + + + + + + + + + + + + {data.interestType ? ( + + + + + ) : null} + {data.comments ? ( + + + + + ) : null} + +
Name{data.fullName}
Email{data.email}
Interest{data.interestType}
Comments{data.comments}
+ {data.crmDeepLink ? ( +
+ +
+ ) : null} + - {portName} CRM + + ); +} + +export async function contactFormSalesAlert( + data: ContactFormSalesAlertData, + overrides?: RenderOpts, +) { + const portName = data.portName ?? 'our team'; + const subject = overrides?.subject?.trim() + ? overrides.subject + : `New contact form submission - ${data.fullName}`; + const accent = brandingPrimaryColor(overrides?.branding); + const body = await render(, { + pretty: false, + }); + return { + subject, + html: renderShell({ title: subject, body, branding: overrides?.branding }), + }; +} diff --git a/src/lib/email/templates/contact-form-client-confirmation.tsx b/src/lib/email/templates/contact-form-client-confirmation.tsx new file mode 100644 index 00000000..8e149a63 --- /dev/null +++ b/src/lib/email/templates/contact-form-client-confirmation.tsx @@ -0,0 +1,81 @@ +import { Link, Text, render } from '@react-email/components'; +import * as React from 'react'; + +import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; + +interface RenderOpts { + branding?: BrandingShell | null; + subject?: string | null; +} + +export interface ContactFormClientConfirmationData { + firstName: string; + contactEmail: string; + portName?: string; +} + +function ClientConfirmationBody({ + portName, + firstName, + contactEmail, + accent, +}: { + portName: string; + firstName: string; + contactEmail: string; + accent: string; +}) { + return ( + <> + + Thank you for getting in touch + + + Dear {firstName}, + + + Thank you for reaching out to {portName}. We have received your message and a member of our + team will be in touch with you shortly. + + + If anything else comes to mind in the meantime, please write to us at{' '} + + {contactEmail} + + . + + + With warm regards, +
+ The {portName} Team +
+ + ); +} + +export async function contactFormClientConfirmation( + data: ContactFormClientConfirmationData, + overrides?: RenderOpts, +) { + const portName = data.portName ?? 'our team'; + const subject = overrides?.subject?.trim() + ? overrides.subject + : `Thank you for contacting ${portName}`; + const accent = brandingPrimaryColor(overrides?.branding); + const body = await render( + , + { pretty: false }, + ); + return { + subject, + html: renderShell({ title: subject, body, branding: overrides?.branding }), + }; +} diff --git a/src/lib/services/inquiry-notifications.service.ts b/src/lib/services/inquiry-notifications.service.ts index f9ea35de..a0febdd0 100644 --- a/src/lib/services/inquiry-notifications.service.ts +++ b/src/lib/services/inquiry-notifications.service.ts @@ -146,7 +146,7 @@ export async function sendInquiryNotifications(params: InquiryNotificationParams /** * Finds all user IDs on a port whose role grants `interests.view` permission. */ -async function findUsersWithInterestsPermission(portId: string): Promise { +export async function findUsersWithInterestsPermission(portId: string): Promise { const assignments = await db .select({ userId: userPortRoles.userId, diff --git a/src/lib/services/website-intake-email.service.ts b/src/lib/services/website-intake-email.service.ts new file mode 100644 index 00000000..50b97ab0 --- /dev/null +++ b/src/lib/services/website-intake-email.service.ts @@ -0,0 +1,286 @@ +/** + * CRM-owned emails for captured website inquiries. + * + * The marketing website dual-writes every inquiry into `website_submissions` + * (capture-only). At cutover, email ownership moves from the website to the + * CRM: when the per-port flag `website_intake_email_enabled` is ON, the CRM + * sends the registrant confirmation + staff alert for each fresh submission, + * reusing the existing branded inquiry templates. Default OFF, so the website + * keeps sending until the flip and we never double-send. + * + * Sends are inline + fire-and-forget (the caller wraps in `void ...catch`): + * a send failure must never 500 the public capture endpoint. Dedup is handled + * upstream by invoking this only on a fresh (non-redelivered) insert. + */ + +import { and, eq, isNull, or } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { systemSettings } from '@/lib/db/schema/system'; +import { sendEmail } from '@/lib/email'; +import { getBrandingShell } from '@/lib/email/branding-resolver'; +import { resolveSubject } from '@/lib/email/resolve-subject'; +import { inquiryClientConfirmation } from '@/lib/email/templates/inquiry-client-confirmation'; +import { inquirySalesNotification } from '@/lib/email/templates/inquiry-sales-notification'; +import { + residentialClientConfirmation, + residentialSalesAlert, +} from '@/lib/email/templates/residential-inquiry'; +import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert'; +import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation'; +import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config'; +import { getSetting } from '@/lib/services/settings.service'; +import { extractInquiryFields } from '@/lib/services/website-intake-fields'; +import { createNotification } from '@/lib/services/notifications.service'; +import { findUsersWithInterestsPermission } from '@/lib/services/inquiry-notifications.service'; +import { logger } from '@/lib/logger'; + +/** + * Per-port gate. Default OFF (no row -> disabled), matching the + * `invoices_module_enabled` pattern. + */ +export async function isWebsiteIntakeEmailEnabled(portId: string): Promise { + const row = await db + .select({ value: systemSettings.value }) + .from(systemSettings) + .where( + and( + eq(systemSettings.key, 'website_intake_email_enabled'), + or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)), + ), + ) + .limit(1); + return row[0]?.value === true; +} + +/** + * Resolve staff-alert recipients for a port: prefer the kind-specific list + * setting, fall back to the single `inquiry_contact_email`. Returns [] when + * nothing is configured (caller then skips the alert). + */ +async function resolveRecipients(portId: string, primaryKey: string): Promise { + const primary = await getSetting(primaryKey, portId); + const list = Array.isArray(primary?.value) + ? primary.value.filter((v): v is string => typeof v === 'string') + : []; + if (list.length > 0) return list; + + const fallback = await getSetting('inquiry_contact_email', portId); + return typeof fallback?.value === 'string' && fallback.value.length > 0 ? [fallback.value] : []; +} + +export interface WebsiteSubmissionEmailInput { + portId: string; + portSlug: string; + kind: string; + submissionId: string; + payload: Record; +} + +export async function sendWebsiteSubmissionEmails( + input: WebsiteSubmissionEmailInput, +): Promise { + const { portId, portSlug, kind, payload } = input; + const fields = extractInquiryFields(payload); + + const [branding, portBrand, emailCfg] = await Promise.all([ + getBrandingShell(portId), + getPortBrandingConfig(portId).catch(() => null), + getPortEmailConfig(portId).catch(() => null), + ]); + const portName = portBrand?.appName ?? 'Port Nimara'; + const contactEmail = emailCfg?.fromAddress ?? 'sales@portnimara.com'; + // No interest/client row exists for a raw submission, so link to the + // dashboard rather than a (nonexistent) entity detail page. + const crmUrl = `${process.env.APP_URL ?? ''}/${portSlug}`; + + if (kind === 'berth_inquiry') { + if (fields.email) { + const confirmation = await inquiryClientConfirmation( + { + firstName: fields.firstName, + mooringNumber: fields.mooringNumber, + contactEmail, + portName, + }, + { branding }, + ); + const subject = await resolveSubject({ + key: 'inquiry_client_confirmation', + portId, + fallback: confirmation.subject, + tokens: { + portName, + recipientName: fields.firstName, + mooringNumber: fields.mooringNumber ?? '', + }, + }); + await sendEmail( + fields.email, + subject, + confirmation.html, + undefined, + confirmation.text, + portId, + ); + } + + const recipients = await resolveRecipients(portId, 'inquiry_notification_recipients'); + if (recipients.length > 0) { + const alert = await inquirySalesNotification( + { + fullName: fields.fullName, + email: fields.email, + phone: fields.phone, + mooringNumber: fields.mooringNumber, + crmUrl, + portName, + }, + { branding }, + ); + const subject = await resolveSubject({ + key: 'inquiry_sales_notification', + portId, + fallback: alert.subject, + tokens: { + portName, + clientName: fields.fullName, + mooringNumber: fields.mooringNumber ?? '', + email: fields.email, + }, + }); + await sendEmail(recipients, subject, alert.html, undefined, alert.text, portId); + } + return; + } + + if (kind === 'residence_inquiry') { + if (fields.email) { + const confirmation = await residentialClientConfirmation( + { firstName: fields.firstName, contactEmail, portName }, + { branding }, + ); + const subject = await resolveSubject({ + key: 'residential_inquiry_client_confirmation', + portId, + fallback: confirmation.subject, + tokens: { portName, recipientName: fields.firstName }, + }); + await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId); + } + + const recipients = await resolveRecipients(portId, 'residential_notification_recipients'); + if (recipients.length > 0) { + const alert = await residentialSalesAlert( + { + fullName: fields.fullName, + email: fields.email, + phone: fields.phone, + placeOfResidence: fields.placeOfResidence ?? undefined, + notes: fields.comments ?? undefined, + crmDeepLink: crmUrl, + portName, + }, + { branding }, + ); + const subject = await resolveSubject({ + key: 'residential_inquiry_sales_alert', + portId, + fallback: alert.subject, + tokens: { portName, clientName: fields.fullName, email: fields.email, phone: fields.phone }, + }); + await sendEmail(recipients, subject, alert.html, undefined, undefined, portId); + } + return; + } + + if (kind === 'contact_form') { + // Client confirmation: a "thanks, we received your message" auto-reply. + // This is CRM-only (the website never sent one), so there is no + // double-send risk; it simply starts once the port flips the flag on. + if (fields.email) { + const confirmation = await contactFormClientConfirmation( + { firstName: fields.firstName, contactEmail, portName }, + { branding }, + ); + const subject = await resolveSubject({ + key: 'contact_form_client_confirmation', + portId, + fallback: confirmation.subject, + tokens: { portName, recipientName: fields.firstName }, + }); + await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId); + } + + const recipients = await resolveRecipients(portId, 'inquiry_notification_recipients'); + if (recipients.length > 0) { + const alert = await contactFormSalesAlert( + { + fullName: fields.fullName, + email: fields.email, + interestType: fields.interestType, + comments: fields.comments, + crmDeepLink: crmUrl, + portName, + }, + { branding }, + ); + const subject = await resolveSubject({ + key: 'contact_form_sales_alert', + portId, + fallback: alert.subject, + tokens: { portName, clientName: fields.fullName, email: fields.email }, + }); + await sendEmail(recipients, subject, alert.html, undefined, undefined, portId); + } + return; + } + + logger.warn({ kind }, 'website-intake email: unknown submission kind, no email sent'); +} + +const KIND_LABEL: Record = { + berth_inquiry: 'berth inquiry', + residence_inquiry: 'residential inquiry', + contact_form: 'contact form', +}; + +/** + * In-app (bell) notifications for a captured website submission. Fires on + * every fresh capture, independent of the email-ownership flag, so reps see + * incoming website inquiries in the CRM inbox even before email cutover. + * Fire-and-forget; deduped per submission. + */ +export async function notifyWebsiteSubmissionInApp(input: { + portId: string; + portSlug: string; + kind: string; + submissionId: string; + payload: Record; +}): Promise { + const { portId, portSlug, kind, submissionId, payload } = input; + const userIds = await findUsersWithInterestsPermission(portId); + if (userIds.length === 0) return; + + const fields = extractInquiryFields(payload); + const who = fields.fullName || 'A visitor'; + const label = KIND_LABEL[kind] ?? 'inquiry'; + const description = `${who} submitted a ${label} via the website`; + const link = `/${portSlug}/inbox`; + + await Promise.allSettled( + userIds.map((userId) => + createNotification({ + portId, + userId, + type: 'new_registration', + title: 'New website inquiry', + description, + link, + entityType: 'website_submission', + entityId: submissionId, + dedupeKey: `website-submission-${submissionId}`, + }), + ), + ); +} diff --git a/src/lib/services/website-intake-fields.ts b/src/lib/services/website-intake-fields.ts new file mode 100644 index 00000000..7ad2d3db --- /dev/null +++ b/src/lib/services/website-intake-fields.ts @@ -0,0 +1,58 @@ +/** + * Pure mapping from the marketing website's raw inquiry payload into the + * fields the CRM email templates need. + * + * The website dual-writes each form submission's body verbatim into + * `website_submissions.payload` (snake_case keys). There is no `clients` / + * `interests` row for a raw submission, so the email path reads straight from + * the payload. Kept pure + dependency-free so it is trivially unit-testable + * and defensive: any missing or non-string field degrades to '' or null + * rather than throwing. + */ + +export interface InquiryFields { + firstName: string; + lastName: string; + fullName: string; + email: string; + phone: string; + /** From the berth form's `berth` field (the mooring number). */ + mooringNumber: string | null; + /** From the residence form's `address` field. */ + placeOfResidence: string | null; + /** From the contact form's free-text `comments` field. */ + comments: string | null; + /** The contact form's `interest` (string or string[]) joined for display. */ + interestType: string | null; +} + +function str(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function extractInquiryFields(payload: Record): InquiryFields { + const firstName = str(payload.first_name); + const lastName = str(payload.last_name); + const email = str(payload.email); + const phone = str(payload.phone); + const mooringNumber = str(payload.berth) || null; + const placeOfResidence = str(payload.address) || null; + const comments = str(payload.comments) || null; + + const rawInterest = payload.interest; + const interestType = Array.isArray(rawInterest) + ? rawInterest.filter((v): v is string => typeof v === 'string').join(', ') || null + : str(rawInterest) || null; + + return { + firstName, + lastName, + fullName: `${firstName} ${lastName}`.trim(), + email, + phone, + mooringNumber, + placeOfResidence, + comments, + interestType, + }; +} diff --git a/src/lib/settings/registry.ts b/src/lib/settings/registry.ts index ac90676e..d277cf43 100644 --- a/src/lib/settings/registry.ts +++ b/src/lib/settings/registry.ts @@ -662,6 +662,21 @@ export const REGISTRY: SettingEntry[] = [ defaultValue: false, }, + // Operations - Website intake emails. Port-scoped gate for CRM-owned + // website-inquiry emails. OFF by default so the marketing website keeps + // sending its own confirmation + staff-alert emails; flip ON at cutover + // (and turn the website's own sending off) so the CRM is the single owner. + { + key: 'website_intake_email_enabled', + section: 'operations.intake', + label: 'CRM-owned website inquiry emails', + description: + 'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients / residential_notification_recipients (fallback inquiry_contact_email).', + type: 'boolean', + scope: 'port', + defaultValue: false, + }, + // ─── Operations - Residential module ────────────────────────────────────── // Port-scoped gate for the entire Residential surface (sidebar // "Residential" section, /residential/clients + /residential/interests diff --git a/tests/unit/website-intake-fields.test.ts b/tests/unit/website-intake-fields.test.ts new file mode 100644 index 00000000..0c6d251c --- /dev/null +++ b/tests/unit/website-intake-fields.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; + +import { extractInquiryFields } from '@/lib/services/website-intake-fields'; + +describe('extractInquiryFields', () => { + it('maps a berth inquiry payload (berth -> mooringNumber)', () => { + const f = extractInquiryFields({ + first_name: 'Jane', + last_name: 'Doe', + email: 'jane@example.com', + phone: '+15551234', + berth: 'A1', + interest: 'berths', + }); + expect(f).toMatchObject({ + firstName: 'Jane', + lastName: 'Doe', + fullName: 'Jane Doe', + email: 'jane@example.com', + phone: '+15551234', + mooringNumber: 'A1', + placeOfResidence: null, + }); + }); + + it('maps a residence inquiry payload (address -> placeOfResidence, no mooring)', () => { + const f = extractInquiryFields({ + first_name: 'Sam', + last_name: 'Lee', + email: 's@example.com', + phone: '2', + address: 'London', + interest: 'residences', + }); + expect(f.mooringNumber).toBeNull(); + expect(f.placeOfResidence).toBe('London'); + expect(f.fullName).toBe('Sam Lee'); + }); + + it('maps a contact form payload (interest[] -> joined interestType + comments)', () => { + const f = extractInquiryFields({ + first_name: 'Ann', + last_name: 'Poe', + email: 'a@example.com', + interest: ['owner', 'broker'], + comments: 'Please call me', + }); + expect(f.interestType).toBe('owner, broker'); + expect(f.comments).toBe('Please call me'); + expect(f.phone).toBe(''); + }); + + it('trims whitespace and degrades missing/garbage fields safely', () => { + const f = extractInquiryFields({ first_name: ' Jo ', last_name: 42 as unknown }); + expect(f.firstName).toBe('Jo'); + expect(f.fullName).toBe('Jo'); + expect(f.email).toBe(''); + expect(f.mooringNumber).toBeNull(); + expect(f.interestType).toBeNull(); + }); + + it('returns all-empty for an empty payload', () => { + expect(extractInquiryFields({})).toMatchObject({ + firstName: '', + lastName: '', + fullName: '', + email: '', + phone: '', + mooringNumber: null, + placeOfResidence: null, + comments: null, + interestType: null, + }); + }); +});