2 Commits

Author SHA1 Message Date
b2692839f1 feat(admin): set a sign-in username when creating a user
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m4s
Build & Push Docker Images / build-and-push (push) Successful in 9m11s
The New User form had no username field, so users created through it
could only sign in by email. Add an optional username to the create
flow (form + createUserSchema + createUser service), validated up front
(shape via USERNAME_REGEX, reserved-list, case-insensitive uniqueness)
before the auth account is minted. Fix the stale schema comment (2–30,
not 3–30; enforced in-app, no DB CHECK constraint exists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-25 22:57:24 +02:00
caaebd77fa fix(intake): client inquiry emails mirror website copy + never show "Port Nimara CRM"
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m49s
Client-facing confirmation emails now:
- use the PUBLIC port name ("Port Nimara" via ports.name), never the CRM
  appName ("Port Nimara CRM") which is reserved for internal/staff surfaces
- mirror the website's wording verbatim ("Thank you for expressing
  interest…", "Best regards,") and drop the CRM-style headings
- sign off per category: berth → "Port Nimara Sales Team", contact →
  "Port Nimara Team", residential → "Port Nimara Residences Team"
- show + reply-to a public contact address, admin-configurable per category
  (inquiry_contact_email → sales@ for berth/residence,
  contact_form_contact_email → hello@ for contact form), never the noreply From

Internal alerts keep the CRM detail-line format + link (name fixed to
"Port Nimara"), EXCEPT the residential alert which drops all CRM mention
(it reaches an external recipient) and signs "- Port Nimara Residences".

sendEmail gains an optional per-message replyTo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-25 22:07:47 +02:00
12 changed files with 256 additions and 110 deletions

View File

@@ -141,9 +141,17 @@ const KNOWN_SETTINGS: Array<{
},
{
key: 'inquiry_contact_email',
label: 'Inquiry Contact Email',
label: 'Berth & residence reply-to email',
description:
'Reply-to email shown in client confirmation emails when a new interest is registered',
'Public "reach out to us at …" address shown to clients in berth + residence inquiry confirmation emails. Defaults to sales@portnimara.com when blank.',
type: 'string',
defaultValue: '',
},
{
key: 'contact_form_contact_email',
label: 'Contact-form reply-to email',
description:
'Public "reach out to us at …" address shown to clients in contact-form confirmation emails. Defaults to hello@portnimara.com when blank.',
type: 'string',
defaultValue: '',
},

View File

@@ -93,6 +93,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
// password here. Toggle off to set one manually.
const [sendSetupEmail, setSendSetupEmail] = useState(true);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
// New users: optional sign-in username (they can also sign in with their
// email). Lowercased on the way out; the API validates shape + uniqueness.
const [username, setUsername] = useState('');
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
user?.phone ? { e164: user.phone, country: 'US' } : null,
);
@@ -153,6 +156,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
password: sendSetupEmail ? undefined : password,
sendSetupEmail,
displayName,
username: username.trim() ? username.trim().toLowerCase() : undefined,
phone: phoneE164 ?? undefined,
roleId,
residentialAccess,
@@ -236,6 +240,26 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
</p>
</div>
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="user-username">Username (optional)</Label>
<Input
id="user-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="e.g. abbie"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
Lets them sign in with a short username instead of their email. 230 lowercase
letters, digits, dot, underscore, or hyphen. Leave blank to sign in by email
only.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input

View File

@@ -303,10 +303,10 @@ export const userProfiles = pgTable(
displayName: text('display_name').notNull(),
/**
* Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen,
* 330 chars (shape pinned by `chk_user_profiles_username_shape`).
* Case-insensitive uniqueness is enforced by a partial unique index on
* LOWER(username); NULL allows the column to coexist with users who
* still sign in by email. See migration 0054.
* 230 chars (shape enforced in-app by `USERNAME_REGEX` in
* `@/lib/validators/username`). Case-insensitive uniqueness is enforced
* by a partial unique index on LOWER(username); NULL allows the column to
* coexist with users who still sign in by email. See migration 0054.
*/
username: text('username'),
avatarUrl: text('avatar_url'),

View File

@@ -128,6 +128,10 @@ export async function sendEmail(
// the safety net.
cc?: string | string[],
bcc?: string | string[],
// Optional per-message Reply-To. Overrides the port's `email_reply_to`
// setting (`cfg.replyTo`) when provided — used so client inquiry
// confirmations reply to the public sales@/hello@ inbox, not the noreply From.
replyTo?: string,
): Promise<nodemailer.SentMessageInfo> {
const cfg = portId ? await getPortEmailConfig(portId) : null;
const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
@@ -150,13 +154,14 @@ export async function sendEmail(
`Port Nimara CRM <noreply@${env.SMTP_HOST}>`;
const resolvedAttachments = await resolveAttachments(attachments, portId);
const effectiveReplyTo = replyTo ?? cfg?.replyTo ?? undefined;
const info = await transporter.sendMail({
from: fromHeader,
to: effectiveTo,
subject: effectiveSubject,
html,
...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}),
...(effectiveReplyTo ? { replyTo: effectiveReplyTo } : {}),
...(text ? { text } : {}),
...(effectiveCc ? { cc: effectiveCc } : {}),
...(effectiveBcc ? { bcc: effectiveBcc } : {}),

View File

@@ -27,18 +27,13 @@ function ClientConfirmationBody({
}) {
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Thank you for getting in touch
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Thank you for contacting {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' }}>
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{' '}
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
If you have any questions in the meantime, please feel free to reach out to us at{' '}
<Link
href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }}
@@ -47,10 +42,10 @@ function ClientConfirmationBody({
</Link>
.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<Text style={{ fontSize: '16px' }}>
Best regards,
<br />
<strong>The {portName} Team</strong>
The {portName} Team
</Text>
</>
);
@@ -60,10 +55,10 @@ export async function contactFormClientConfirmation(
data: ContactFormClientConfirmationData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'our team';
const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim()
? overrides.subject
: `Thank you for contacting ${portName}`;
: `${portName}Thank You for Contacting Us`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
<ClientConfirmationBody
@@ -74,8 +69,19 @@ export async function contactFormClientConfirmation(
/>,
{ pretty: false },
);
const text = [
`Dear ${data.firstName},`,
'',
`Thank you for contacting ${portName}. We have received your message and a member of our team will be in touch with you shortly.`,
'',
`If you have any questions in the meantime, please feel free to reach out to us at ${data.contactEmail}.`,
'',
'Best regards,',
`The ${portName} Team`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -32,12 +32,12 @@ function ClientConfirmationBody({
<>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Thank you for your interest in {berthText}. We&apos;ve noted your enquiry, and a member of
our team will be in touch shortly through your preferred channel with the details
you&apos;ve requested.
Thank you for expressing interest in {berthText}. Our team has registered your interest, and
we will reach out to you very shortly by your preferred method of contact with more
information.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Should anything come to mind in the meantime, please don&apos;t hesitate to write to us at{' '}
If you have any questions, please feel free to reach out to us at{' '}
<Link
href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }}
@@ -47,7 +47,7 @@ function ClientConfirmationBody({
.
</Text>
<Text style={{ fontSize: '16px' }}>
With warm regards,
Best regards,
<br />
The {portName} Sales Team
</Text>
@@ -61,12 +61,10 @@ export async function inquiryClientConfirmation(
) {
const { firstName, mooringNumber, contactEmail } = data;
const portName = data.portName ?? 'Port Nimara';
const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
const berthText = mooringNumber ? `Berth ${mooringNumber}` : 'a Berth';
const subject = overrides?.subject?.trim()
? overrides.subject
: mooringNumber
? `Thank you for your interest in Berth ${mooringNumber}`
: `Thank you for your interest in ${portName}`;
: `${portName} — Thank You for Your Interest`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
@@ -83,11 +81,11 @@ export async function inquiryClientConfirmation(
const text = [
`Dear ${firstName},`,
'',
`Thank you for your interest in ${berthText}. We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel with the details you've requested.`,
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
'',
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${contactEmail}.`,
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
'',
'With warm regards,',
'Best regards,',
`The ${portName} Sales Team`,
].join('\n');

View File

@@ -16,14 +16,13 @@ export interface ResidentialClientConfirmationData {
}
/**
* Human-readable list of the residence types a lead selected, e.g.
* Human-readable phrase for the residence types a lead selected, e.g.
* "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa".
* Falls back to a generic phrase when nothing was selected so the copy
* always reads naturally.
* Mirrors the website's phrasing, including its generic fallback.
*/
function residencePhrase(portName: string, types: string[] | undefined): string {
const list = (types ?? []).filter(Boolean);
if (list.length === 0) return `the residences at ${portName}`;
if (list.length === 0) return `a ${portName} Residence`;
if (list.length === 1) return `the ${list[0]}`;
if (list.length === 2) return `the ${list[0]} and the ${list[1]}`;
return `the ${list.slice(0, -1).join(', the ')}, and the ${list[list.length - 1]}`;
@@ -44,19 +43,14 @@ function ClientConfirmationBody({
}) {
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Welcome to {portName}
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Thank you for expressing interest in {residencePhraseText}. Our team has registered your
interest, and we will reach out to you very shortly by your preferred method of contact with
more information.
</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 your interest in {residencePhraseText}. Our residential sales team has
received your enquiry, and a member of the team will be in touch shortly with the details
you&apos;ve requested.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
Should anything come to mind in the meantime, please don&apos;t hesitate to write to us at{' '}
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
If you have any questions, please feel free to reach out to us at{' '}
<Link
href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }}
@@ -65,10 +59,10 @@ function ClientConfirmationBody({
</Link>
.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<Text style={{ fontSize: '16px' }}>
Best regards,
<br />
<strong>The {portName} Residential Team</strong>
The {portName} Residences Team
</Text>
</>
);
@@ -78,10 +72,10 @@ export async function residentialClientConfirmation(
data: ResidentialClientConfirmationData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'our team';
const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim()
? overrides.subject
: `Thank you for your interest in ${portName} Residences`;
: `${portName}Thank You for Your Interest`;
const accent = brandingPrimaryColor(overrides?.branding);
const residencePhraseText = residencePhrase(portName, data.residenceTypes);
const body = await render(
@@ -97,12 +91,12 @@ export async function residentialClientConfirmation(
const text = [
`Dear ${data.firstName},`,
'',
`Thank you for your interest in ${residencePhraseText}. Our residential sales team has received your enquiry, and a member of the team will be in touch shortly with the details you've requested.`,
`Thank you for expressing interest in ${residencePhraseText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
'',
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${data.contactEmail}.`,
`If you have any questions, please feel free to reach out to us at ${data.contactEmail}.`,
'',
'With warm regards,',
`The ${portName} Residential Team`,
'Best regards,',
`The ${portName} Residences Team`,
].join('\n');
return {
subject,
@@ -120,6 +114,11 @@ export interface ResidentialSalesAlertData {
preferredContactMethod?: 'email' | 'phone';
notes?: string;
preferences?: string;
/**
* Accepted for backwards-compat with the legacy `/api/public/residential-inquiries`
* route, but intentionally NOT rendered: residential alerts go to external
* recipients and must never mention the CRM.
*/
crmDeepLink?: string;
portName?: string;
}
@@ -130,15 +129,7 @@ function formatPreferredContact(method: 'email' | 'phone' | undefined): string |
return undefined;
}
function SalesAlertBody({
portName,
data,
accent,
}: {
portName: string;
data: ResidentialSalesAlertData;
accent: string;
}) {
function SalesAlertBody({ portName, data }: { portName: string; data: ResidentialSalesAlertData }) {
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
const preferredContact = formatPreferredContact(data.preferredContactMethod);
@@ -185,19 +176,7 @@ function SalesAlertBody({
</Text>
) : null}
</div>
{data.crmDeepLink ? (
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Open the{' '}
<Link
href={safeUrl(data.crmDeepLink)}
style={{ color: accent, textDecoration: 'underline' }}
>
{portName} CRM
</Link>{' '}
to follow up.
</Text>
) : null}
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
<Text style={{ fontSize: '16px' }}>- {portName} Residences</Text>
</>
);
}
@@ -206,12 +185,11 @@ export async function residentialSalesAlert(
data: ResidentialSalesAlertData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'our team';
const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim()
? overrides.subject
: `New residential enquiry - ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
const body = await render(<SalesAlertBody portName={portName} data={data} />, {
pretty: false,
});
@@ -231,10 +209,7 @@ export async function residentialSalesAlert(
...(data.preferences ? [`Preferences: ${data.preferences}`] : []),
...(data.notes ? [`Comments: ${data.notes}`] : []),
'',
...(data.crmDeepLink
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
: []),
`- ${portName} CRM`,
`- ${portName} Residences`,
].join('\n');
return {

View File

@@ -1,10 +1,11 @@
import { and, eq } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import { USERNAME_REGEX, isReservedUsername } from '@/lib/validators/username';
import { emitToRoom } from '@/lib/socket/server';
import { sendEmail } from '@/lib/email';
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
@@ -153,6 +154,31 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
});
if (!role) throw new ValidationError('Invalid role ID');
// Optional sign-in username. Validated up front (before the auth user is
// minted) so an invalid/taken username can't leave an orphaned account.
// Mirrors the self-service /api/v1/me checks: shape + reserved + unique.
let username: string | null = null;
if (data.username && data.username.trim()) {
const candidate = data.username.trim().toLowerCase();
if (!USERNAME_REGEX.test(candidate)) {
throw new ValidationError(
'Username must be 230 lowercase letters, digits, dot, underscore, or hyphen.',
);
}
if (isReservedUsername(candidate)) {
throw new ValidationError('That username is reserved. Please pick another.');
}
const taken = await db
.select({ userId: userProfiles.userId })
.from(userProfiles)
.where(sql`LOWER(${userProfiles.username}) = ${candidate}`)
.limit(1);
if (taken.length > 0) {
throw new ConflictError('That username is already taken.');
}
username = candidate;
}
// Two onboarding modes:
// - setup-email (default when no password is supplied): provision the
// account with a throwaway random password the admin never sees, then
@@ -179,6 +205,7 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
await db.insert(userProfiles).values({
userId: newUserId,
displayName: data.displayName,
username,
firstName: data.firstName ?? null,
lastName: data.lastName ?? null,
phone: data.phone ?? null,

View File

@@ -16,6 +16,7 @@
import { and, eq, isNull, or } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { systemSettings } from '@/lib/db/schema/system';
import { sendEmail } from '@/lib/email';
import { getBrandingShell } from '@/lib/email/branding-resolver';
@@ -28,7 +29,6 @@ import {
} 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 { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
import { createNotification } from '@/lib/services/notifications.service';
@@ -77,13 +77,36 @@ export async function sendWebsiteSubmissionEmails(
const { portId, portSlug, kind, payload } = input;
const fields = extractInquiryFields(payload);
const [branding, portBrand, emailCfg] = await Promise.all([
const [branding, portRow] = await Promise.all([
getBrandingShell(portId),
getPortBrandingConfig(portId).catch(() => null),
getPortEmailConfig(portId).catch(() => null),
db.select({ name: ports.name }).from(ports).where(eq(ports.id, portId)).limit(1),
]);
const portName = portBrand?.appName ?? 'Port Nimara';
const contactEmail = emailCfg?.fromAddress ?? 'sales@portnimara.com';
// Client-facing copy uses the PUBLIC port name ("Port Nimara"), never the CRM
// appName ("Port Nimara CRM") which is reserved for internal/staff surfaces.
const portName = portRow[0]?.name ?? 'Port Nimara';
// Public reply-to shown to clients in confirmation emails ("reach out to us
// at ..."). Admin-configurable per category via system_settings; contact-form
// enquiries default to the hello@ general inbox, berth + residence to sales@.
// Never the noreply From address.
const contactEmailKey =
kind === 'contact_form' ? 'contact_form_contact_email' : 'inquiry_contact_email';
const contactEmailDefault =
kind === 'contact_form' ? 'hello@portnimara.com' : 'sales@portnimara.com';
const [contactRow] = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(
and(
eq(systemSettings.key, contactEmailKey),
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
),
)
.limit(1);
const contactEmail =
typeof contactRow?.value === 'string' && contactRow.value.trim()
? contactRow.value.trim()
: contactEmailDefault;
// 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}`;
@@ -116,6 +139,10 @@ export async function sendWebsiteSubmissionEmails(
undefined,
confirmation.text,
portId,
undefined,
undefined,
undefined,
contactEmail,
);
}
@@ -172,6 +199,10 @@ export async function sendWebsiteSubmissionEmails(
undefined,
confirmation.text,
portId,
undefined,
undefined,
undefined,
contactEmail,
);
}
@@ -186,7 +217,6 @@ export async function sendWebsiteSubmissionEmails(
preferredContactMethod: fields.preferredContact ?? undefined,
placeOfResidence: fields.placeOfResidence ?? undefined,
notes: fields.comments ?? undefined,
crmDeepLink: crmUrl,
portName,
},
{ branding },
@@ -217,7 +247,18 @@ export async function sendWebsiteSubmissionEmails(
fallback: confirmation.subject,
tokens: { portName, recipientName: fields.firstName },
});
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
await sendEmail(
fields.email,
subject,
confirmation.html,
undefined,
confirmation.text,
portId,
undefined,
undefined,
undefined,
contactEmail,
);
}
// Contact-form alerts go to their own recipient list (the website routed

View File

@@ -13,6 +13,9 @@ export const createUserSchema = z
* password. When false, `password` must be supplied inline. */
sendSetupEmail: z.boolean().optional(),
displayName: z.string().min(1).max(200),
/** Optional sign-in username. Shape/uniqueness/reserved checks run in the
* service (mirrors the self-service /api/v1/me path). Omit for email-only. */
username: z.string().optional(),
firstName: z.string().min(1).max(200).nullable().optional(),
lastName: z.string().min(1).max(200).nullable().optional(),
phone: z.string().optional(),

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { inquiryClientConfirmation } from '@/lib/email/templates/inquiry-client-confirmation';
import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation';
// Note: assert prose that spans an interpolation on the plain-text part — the
// React-Email HTML renderer inserts `<!-- -->` markers at value boundaries.
describe('inquiryClientConfirmation (berth)', () => {
it('mirrors the website copy for a specific berth + signs off as Sales', async () => {
const { subject, html, text } = await inquiryClientConfirmation({
firstName: 'Jane',
mooringNumber: 'D13',
contactEmail: 'sales@portnimara.com',
portName: 'Port Nimara',
});
expect(subject).toBe('Port Nimara — Thank You for Your Interest');
expect(text).toContain('Thank you for expressing interest in Berth D13');
expect(text).toContain('Our team has registered your interest');
expect(text).toContain('reach out to us at sales@portnimara.com');
expect(text).toContain('The Port Nimara Sales Team');
expect(html).toContain('sales@portnimara.com');
expect(html).not.toContain('Port Nimara CRM');
});
it('uses "a Berth" when no mooring is given', async () => {
const { text, html } = await inquiryClientConfirmation({
firstName: 'Jane',
mooringNumber: null,
contactEmail: 'sales@portnimara.com',
portName: 'Port Nimara',
});
expect(text).toContain('Thank you for expressing interest in a Berth');
expect(html).not.toContain('Port Nimara CRM');
});
});
describe('contactFormClientConfirmation', () => {
it('mirrors the website copy + signs off as the Port Nimara Team', async () => {
const { subject, html, text } = await contactFormClientConfirmation({
firstName: 'Bob',
contactEmail: 'hello@portnimara.com',
portName: 'Port Nimara',
});
expect(subject).toBe('Port Nimara — Thank You for Contacting Us');
expect(text).toContain('Thank you for contacting Port Nimara');
expect(text).toContain('We have received your message');
expect(text).toContain('reach out to us at hello@portnimara.com');
expect(text).toContain('The Port Nimara Team');
expect(html).not.toContain('Port Nimara CRM');
});
});

View File

@@ -6,30 +6,36 @@ import {
} from '@/lib/email/templates/residential-inquiry';
describe('residentialClientConfirmation', () => {
it('reflects the chosen residence types in the thank-you copy', async () => {
const { html, text } = await residentialClientConfirmation({
it('mirrors the website copy + reflects the chosen residence types', async () => {
const { subject, html, text } = await residentialClientConfirmation({
firstName: 'Mia',
contactEmail: 'sales@portnimara.com',
residenceTypes: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'],
portName: 'Port Nimara',
});
expect(html).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa');
expect(text).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa');
expect(html).toContain('Mia');
expect(subject).toBe('Port Nimara — Thank You for Your Interest');
expect(text).toContain(
'Thank you for expressing interest in the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa',
);
expect(text).toContain('Our team has registered your interest');
expect(text).toContain('The Port Nimara Residences Team');
// Never leak the CRM brand name to a client.
expect(html).not.toContain('Port Nimara CRM');
});
it('falls back to a generic phrase when no types are selected', async () => {
it('falls back to the website generic phrase when no types are selected', async () => {
const { html } = await residentialClientConfirmation({
firstName: 'Sam',
contactEmail: 'sales@portnimara.com',
portName: 'Port Nimara',
});
expect(html).toContain('the residences at Port Nimara');
expect(html).toContain('a Port Nimara Residence');
expect(html).not.toContain('Port Nimara CRM');
});
});
describe('residentialSalesAlert', () => {
it('renders residence type(s) + preferred contact + comments in the detail-line format', async () => {
it('renders residence type(s) + preferred contact + comments, with NO CRM mention', async () => {
const { html, text } = await residentialSalesAlert({
fullName: 'Mia Ng',
email: 'mia@example.com',
@@ -37,21 +43,22 @@ describe('residentialSalesAlert', () => {
residenceTypes: ['Two Bedroom Marina Villa'],
preferredContactMethod: 'phone',
notes: 'Looking for a winter completion.',
crmDeepLink: 'https://crm.portnimara.com/port-nimara',
portName: 'Port Nimara',
});
// Uniform with the berth/contact alerts: friendly intro + bold detail lines + CRM link.
expect(html).toContain('A new residential enquiry has come in');
expect(html).toContain('Residence type(s):');
expect(html).toContain('Two Bedroom Marina Villa');
expect(html).toContain('Preferred contact:');
expect(html).toContain('Phone call back');
expect(html).toContain('Looking for a winter completion.');
expect(html).toContain('to follow up');
// Plain-text part mirrors the other alerts.
expect(text).toContain('- Port Nimara Residences');
// Residential internal alerts must not mention the CRM (recipient is external).
expect(html).not.toContain('CRM');
expect(html).not.toContain('to follow up');
expect(text).toContain('Residence type(s): Two Bedroom Marina Villa');
expect(text).toContain('Preferred contact: Phone call back');
expect(text).toContain('Comments: Looking for a winter completion.');
expect(text).not.toContain('CRM');
});
it('omits optional rows cleanly when absent', async () => {