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:
@@ -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.
|
||||
|
||||
@@ -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<TemplateKey, TemplateMetadata> = {
|
||||
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',
|
||||
|
||||
104
src/lib/email/templates/contact-form-alert.tsx
Normal file
104
src/lib/email/templates/contact-form-alert.tsx
Normal 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 }),
|
||||
};
|
||||
}
|
||||
81
src/lib/email/templates/contact-form-client-confirmation.tsx
Normal file
81
src/lib/email/templates/contact-form-client-confirmation.tsx
Normal 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 }),
|
||||
};
|
||||
}
|
||||
@@ -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<string[]> {
|
||||
export async function findUsersWithInterestsPermission(portId: string): Promise<string[]> {
|
||||
const assignments = await db
|
||||
.select({
|
||||
userId: userPortRoles.userId,
|
||||
|
||||
286
src/lib/services/website-intake-email.service.ts
Normal file
286
src/lib/services/website-intake-email.service.ts
Normal 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}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
58
src/lib/services/website-intake-fields.ts
Normal file
58
src/lib/services/website-intake-fields.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
75
tests/unit/website-intake-fields.test.ts
Normal file
75
tests/unit/website-intake-fields.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user