feat(intake): CRM-owned website inquiry emails + in-app notifications

Flag-gated (website_intake_email_enabled, default OFF) sending of registrant confirmation + staff alert for inquiries captured at /api/public/website-inquiries, reusing the branded berth + residential templates and adding contact-form client-confirmation + sales-alert templates. In-app (bell) notifications fire on every fresh capture, independent of the flag. Recipients resolve from the existing inquiry_/residential_notification_recipients settings; fires only on a fresh (non-deduped) insert so retries never re-send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:22:08 +02:00
parent f699533224
commit 990b566eff
9 changed files with 675 additions and 1 deletions

View File

@@ -16,6 +16,11 @@ import {
} from '@/lib/errors'; } from '@/lib/errors';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import {
isWebsiteIntakeEmailEnabled,
notifyWebsiteSubmissionInApp,
sendWebsiteSubmissionEmails,
} from '@/lib/services/website-intake-email.service';
/** /**
* POST /api/public/website-inquiries * POST /api/public/website-inquiries
@@ -169,6 +174,40 @@ export async function POST(req: NextRequest) {
}, },
'website inquiry captured', '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 // L34 carve-out: deliberate bespoke `{ id, deduped }` shape (NOT the
// `{ data }` envelope). This is the public website's intake contract — // `{ data }` envelope). This is the public website's intake contract —
// the external marketing site reads `id`/`deduped` off the JSON root. // the external marketing site reads `id`/`deduped` off the JSON root.

View File

@@ -22,6 +22,8 @@ export const TEMPLATE_KEYS = [
'inquiry_sales_notification', 'inquiry_sales_notification',
'residential_inquiry_client_confirmation', 'residential_inquiry_client_confirmation',
'residential_inquiry_sales_alert', 'residential_inquiry_sales_alert',
'contact_form_sales_alert',
'contact_form_client_confirmation',
// M-EM04: daily notification digest. The digest service previously // M-EM04: daily notification digest. The digest service previously
// resolved its subject via `'crm_invite' as any` because no entry // resolved its subject via `'crm_invite' as any` because no entry
// existed; making it a first-class key removes the cast and lets // existed; making it a first-class key removes the cast and lets
@@ -101,6 +103,20 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
mergeTokens: ['portName', 'clientName', 'email', 'phone'], mergeTokens: ['portName', 'clientName', 'email', 'phone'],
defaultSubject: 'New residential inquiry - {{clientName}}', 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: { notification_digest: {
key: 'notification_digest', key: 'notification_digest',
label: 'Notification digest', label: 'Notification digest',

View File

@@ -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 (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
New contact form submission
</Text>
<table
role="presentation"
width="100%"
cellPadding={6}
cellSpacing={0}
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }}
>
<tbody>
<tr>
<td style={labelCell}>Name</td>
<td>{data.fullName}</td>
</tr>
<tr>
<td style={labelCell}>Email</td>
<td>{data.email}</td>
</tr>
{data.interestType ? (
<tr>
<td style={labelCell}>Interest</td>
<td>{data.interestType}</td>
</tr>
) : null}
{data.comments ? (
<tr>
<td style={labelCell}>Comments</td>
<td>{data.comments}</td>
</tr>
) : null}
</tbody>
</table>
{data.crmDeepLink ? (
<div style={{ textAlign: 'center', margin: '24px 0' }}>
<Button
href={safeUrl(data.crmDeepLink)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '12px 28px',
borderRadius: '5px',
fontWeight: 'bold',
}}
>
Open in CRM
</Button>
</div>
) : null}
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
</>
);
}
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(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false,
});
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}

View File

@@ -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 (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Thank you for getting in touch
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
Dear {firstName},
</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
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.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
If anything else comes to mind in the meantime, please write to us at{' '}
<Link
href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }}
>
{contactEmail}
</Link>
.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
</>
);
}
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(
<ClientConfirmationBody
portName={portName}
firstName={data.firstName}
contactEmail={data.contactEmail}
accent={accent}
/>,
{ pretty: false },
);
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}

View File

@@ -146,7 +146,7 @@ export async function sendInquiryNotifications(params: InquiryNotificationParams
/** /**
* Finds all user IDs on a port whose role grants `interests.view` permission. * Finds all user IDs on a port whose role grants `interests.view` permission.
*/ */
async function findUsersWithInterestsPermission(portId: string): Promise<string[]> { export async function findUsersWithInterestsPermission(portId: string): Promise<string[]> {
const assignments = await db const assignments = await db
.select({ .select({
userId: userPortRoles.userId, userId: userPortRoles.userId,

View File

@@ -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<boolean> {
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<string[]> {
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<string, unknown>;
}
export async function sendWebsiteSubmissionEmails(
input: WebsiteSubmissionEmailInput,
): Promise<void> {
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<string, string> = {
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<string, unknown>;
}): Promise<void> {
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}`,
}),
),
);
}

View File

@@ -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<string, unknown>): 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,
};
}

View File

@@ -662,6 +662,21 @@ export const REGISTRY: SettingEntry[] = [
defaultValue: false, 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 ────────────────────────────────────── // ─── Operations - Residential module ──────────────────────────────────────
// Port-scoped gate for the entire Residential surface (sidebar // Port-scoped gate for the entire Residential surface (sidebar
// "Residential" section, /residential/clients + /residential/interests // "Residential" section, /residential/clients + /residential/interests

View File

@@ -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,
});
});
});