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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user