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', key: 'inquiry_contact_email',
label: 'Inquiry Contact Email', label: 'Berth & residence reply-to email',
description: 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', type: 'string',
defaultValue: '', defaultValue: '',
}, },

View File

@@ -93,6 +93,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
// password here. Toggle off to set one manually. // password here. Toggle off to set one manually.
const [sendSetupEmail, setSendSetupEmail] = useState(true); const [sendSetupEmail, setSendSetupEmail] = useState(true);
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); 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>( const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
user?.phone ? { e164: user.phone, country: 'US' } : null, user?.phone ? { e164: user.phone, country: 'US' } : null,
); );
@@ -153,6 +156,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
password: sendSetupEmail ? undefined : password, password: sendSetupEmail ? undefined : password,
sendSetupEmail, sendSetupEmail,
displayName, displayName,
username: username.trim() ? username.trim().toLowerCase() : undefined,
phone: phoneE164 ?? undefined, phone: phoneE164 ?? undefined,
roleId, roleId,
residentialAccess, residentialAccess,
@@ -236,6 +240,26 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
</p> </p>
</div> </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"> <div className="space-y-2">
<Label htmlFor="user-email">Email</Label> <Label htmlFor="user-email">Email</Label>
<Input <Input

View File

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

View File

@@ -128,6 +128,10 @@ export async function sendEmail(
// the safety net. // the safety net.
cc?: string | string[], cc?: string | string[],
bcc?: 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> { ): Promise<nodemailer.SentMessageInfo> {
const cfg = portId ? await getPortEmailConfig(portId) : null; const cfg = portId ? await getPortEmailConfig(portId) : null;
const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter(); const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
@@ -150,13 +154,14 @@ export async function sendEmail(
`Port Nimara CRM <noreply@${env.SMTP_HOST}>`; `Port Nimara CRM <noreply@${env.SMTP_HOST}>`;
const resolvedAttachments = await resolveAttachments(attachments, portId); const resolvedAttachments = await resolveAttachments(attachments, portId);
const effectiveReplyTo = replyTo ?? cfg?.replyTo ?? undefined;
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: fromHeader, from: fromHeader,
to: effectiveTo, to: effectiveTo,
subject: effectiveSubject, subject: effectiveSubject,
html, html,
...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}), ...(effectiveReplyTo ? { replyTo: effectiveReplyTo } : {}),
...(text ? { text } : {}), ...(text ? { text } : {}),
...(effectiveCc ? { cc: effectiveCc } : {}), ...(effectiveCc ? { cc: effectiveCc } : {}),
...(effectiveBcc ? { bcc: effectiveBcc } : {}), ...(effectiveBcc ? { bcc: effectiveBcc } : {}),

View File

@@ -27,18 +27,13 @@ function ClientConfirmationBody({
}) { }) {
return ( return (
<> <>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
Thank you for getting in touch <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>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Dear {firstName}, If you have any questions in the meantime, please feel free to reach out to us at{' '}
</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 <Link
href={safeUrl(`mailto:${contactEmail}`)} href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }} style={{ color: accent, textDecoration: 'underline' }}
@@ -47,10 +42,10 @@ function ClientConfirmationBody({
</Link> </Link>
. .
</Text> </Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}> <Text style={{ fontSize: '16px' }}>
With warm regards, Best regards,
<br /> <br />
<strong>The {portName} Team</strong> The {portName} Team
</Text> </Text>
</> </>
); );
@@ -60,10 +55,10 @@ export async function contactFormClientConfirmation(
data: ContactFormClientConfirmationData, data: ContactFormClientConfirmationData,
overrides?: RenderOpts, overrides?: RenderOpts,
) { ) {
const portName = data.portName ?? 'our team'; const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim() const subject = overrides?.subject?.trim()
? overrides.subject ? overrides.subject
: `Thank you for contacting ${portName}`; : `${portName}Thank You for Contacting Us`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render( const body = await render(
<ClientConfirmationBody <ClientConfirmationBody
@@ -74,8 +69,19 @@ export async function contactFormClientConfirmation(
/>, />,
{ pretty: false }, { 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 { return {
subject, subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }), 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' }}>Dear {firstName},</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Thank you for your interest in {berthText}. We&apos;ve noted your enquiry, and a member of Thank you for expressing interest in {berthText}. Our team has registered your interest, and
our team will be in touch shortly through your preferred channel with the details we will reach out to you very shortly by your preferred method of contact with more
you&apos;ve requested. information.
</Text> </Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}> <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 <Link
href={safeUrl(`mailto:${contactEmail}`)} href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }} style={{ color: accent, textDecoration: 'underline' }}
@@ -47,7 +47,7 @@ function ClientConfirmationBody({
. .
</Text> </Text>
<Text style={{ fontSize: '16px' }}> <Text style={{ fontSize: '16px' }}>
With warm regards, Best regards,
<br /> <br />
The {portName} Sales Team The {portName} Sales Team
</Text> </Text>
@@ -61,12 +61,10 @@ export async function inquiryClientConfirmation(
) { ) {
const { firstName, mooringNumber, contactEmail } = data; const { firstName, mooringNumber, contactEmail } = data;
const portName = data.portName ?? 'Port Nimara'; 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() const subject = overrides?.subject?.trim()
? overrides.subject ? overrides.subject
: mooringNumber : `${portName} — Thank You for Your Interest`;
? `Thank you for your interest in Berth ${mooringNumber}`
: `Thank you for your interest in ${portName}`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render( const body = await render(
@@ -83,11 +81,11 @@ export async function inquiryClientConfirmation(
const text = [ const text = [
`Dear ${firstName},`, `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`, `The ${portName} Sales Team`,
].join('\n'); ].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". * "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa".
* Falls back to a generic phrase when nothing was selected so the copy * Mirrors the website's phrasing, including its generic fallback.
* always reads naturally.
*/ */
function residencePhrase(portName: string, types: string[] | undefined): string { function residencePhrase(portName: string, types: string[] | undefined): string {
const list = (types ?? []).filter(Boolean); 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 === 1) return `the ${list[0]}`;
if (list.length === 2) return `the ${list[0]} and the ${list[1]}`; 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]}`; return `the ${list.slice(0, -1).join(', the ')}, and the ${list[list.length - 1]}`;
@@ -44,19 +43,14 @@ function ClientConfirmationBody({
}) { }) {
return ( return (
<> <>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
Welcome to {portName} <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>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Dear {firstName}, If you have any questions, please feel free to reach out to us at{' '}
</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{' '}
<Link <Link
href={safeUrl(`mailto:${contactEmail}`)} href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }} style={{ color: accent, textDecoration: 'underline' }}
@@ -65,10 +59,10 @@ function ClientConfirmationBody({
</Link> </Link>
. .
</Text> </Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}> <Text style={{ fontSize: '16px' }}>
With warm regards, Best regards,
<br /> <br />
<strong>The {portName} Residential Team</strong> The {portName} Residences Team
</Text> </Text>
</> </>
); );
@@ -78,10 +72,10 @@ export async function residentialClientConfirmation(
data: ResidentialClientConfirmationData, data: ResidentialClientConfirmationData,
overrides?: RenderOpts, overrides?: RenderOpts,
) { ) {
const portName = data.portName ?? 'our team'; const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim() const subject = overrides?.subject?.trim()
? overrides.subject ? overrides.subject
: `Thank you for your interest in ${portName} Residences`; : `${portName}Thank You for Your Interest`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const residencePhraseText = residencePhrase(portName, data.residenceTypes); const residencePhraseText = residencePhrase(portName, data.residenceTypes);
const body = await render( const body = await render(
@@ -97,12 +91,12 @@ export async function residentialClientConfirmation(
const text = [ const text = [
`Dear ${data.firstName},`, `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,', 'Best regards,',
`The ${portName} Residential Team`, `The ${portName} Residences Team`,
].join('\n'); ].join('\n');
return { return {
subject, subject,
@@ -120,6 +114,11 @@ export interface ResidentialSalesAlertData {
preferredContactMethod?: 'email' | 'phone'; preferredContactMethod?: 'email' | 'phone';
notes?: string; notes?: string;
preferences?: 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; crmDeepLink?: string;
portName?: string; portName?: string;
} }
@@ -130,15 +129,7 @@ function formatPreferredContact(method: 'email' | 'phone' | undefined): string |
return undefined; return undefined;
} }
function SalesAlertBody({ function SalesAlertBody({ portName, data }: { portName: string; data: ResidentialSalesAlertData }) {
portName,
data,
accent,
}: {
portName: string;
data: ResidentialSalesAlertData;
accent: string;
}) {
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const; const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean); const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
const preferredContact = formatPreferredContact(data.preferredContactMethod); const preferredContact = formatPreferredContact(data.preferredContactMethod);
@@ -185,19 +176,7 @@ function SalesAlertBody({
</Text> </Text>
) : null} ) : null}
</div> </div>
{data.crmDeepLink ? ( <Text style={{ fontSize: '16px' }}>- {portName} Residences</Text>
<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>
</> </>
); );
} }
@@ -206,12 +185,11 @@ export async function residentialSalesAlert(
data: ResidentialSalesAlertData, data: ResidentialSalesAlertData,
overrides?: RenderOpts, overrides?: RenderOpts,
) { ) {
const portName = data.portName ?? 'our team'; const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim() const subject = overrides?.subject?.trim()
? overrides.subject ? overrides.subject
: `New residential enquiry - ${data.fullName}`; : `New residential enquiry - ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding); const body = await render(<SalesAlertBody portName={portName} data={data} />, {
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false, pretty: false,
}); });
@@ -231,10 +209,7 @@ export async function residentialSalesAlert(
...(data.preferences ? [`Preferences: ${data.preferences}`] : []), ...(data.preferences ? [`Preferences: ${data.preferences}`] : []),
...(data.notes ? [`Comments: ${data.notes}`] : []), ...(data.notes ? [`Comments: ${data.notes}`] : []),
'', '',
...(data.crmDeepLink `- ${portName} Residences`,
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
: []),
`- ${portName} CRM`,
].join('\n'); ].join('\n');
return { 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 { db } from '@/lib/db';
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema'; import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import { USERNAME_REGEX, isReservedUsername } from '@/lib/validators/username';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import { sendEmail } from '@/lib/email'; import { sendEmail } from '@/lib/email';
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change'; 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'); 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: // Two onboarding modes:
// - setup-email (default when no password is supplied): provision the // - setup-email (default when no password is supplied): provision the
// account with a throwaway random password the admin never sees, then // 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({ await db.insert(userProfiles).values({
userId: newUserId, userId: newUserId,
displayName: data.displayName, displayName: data.displayName,
username,
firstName: data.firstName ?? null, firstName: data.firstName ?? null,
lastName: data.lastName ?? null, lastName: data.lastName ?? null,
phone: data.phone ?? null, phone: data.phone ?? null,

View File

@@ -16,6 +16,7 @@
import { and, eq, isNull, or } from 'drizzle-orm'; import { and, eq, isNull, or } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { systemSettings } from '@/lib/db/schema/system'; import { systemSettings } from '@/lib/db/schema/system';
import { sendEmail } from '@/lib/email'; import { sendEmail } from '@/lib/email';
import { getBrandingShell } from '@/lib/email/branding-resolver'; import { getBrandingShell } from '@/lib/email/branding-resolver';
@@ -28,7 +29,6 @@ import {
} from '@/lib/email/templates/residential-inquiry'; } from '@/lib/email/templates/residential-inquiry';
import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert'; import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert';
import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation'; 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 { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
import { extractInquiryFields } from '@/lib/services/website-intake-fields'; import { extractInquiryFields } from '@/lib/services/website-intake-fields';
import { createNotification } from '@/lib/services/notifications.service'; import { createNotification } from '@/lib/services/notifications.service';
@@ -77,13 +77,36 @@ export async function sendWebsiteSubmissionEmails(
const { portId, portSlug, kind, payload } = input; const { portId, portSlug, kind, payload } = input;
const fields = extractInquiryFields(payload); const fields = extractInquiryFields(payload);
const [branding, portBrand, emailCfg] = await Promise.all([ const [branding, portRow] = await Promise.all([
getBrandingShell(portId), getBrandingShell(portId),
getPortBrandingConfig(portId).catch(() => null), db.select({ name: ports.name }).from(ports).where(eq(ports.id, portId)).limit(1),
getPortEmailConfig(portId).catch(() => null),
]); ]);
const portName = portBrand?.appName ?? 'Port Nimara'; // Client-facing copy uses the PUBLIC port name ("Port Nimara"), never the CRM
const contactEmail = emailCfg?.fromAddress ?? 'sales@portnimara.com'; // 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 // No interest/client row exists for a raw submission, so link to the
// dashboard rather than a (nonexistent) entity detail page. // dashboard rather than a (nonexistent) entity detail page.
const crmUrl = `${process.env.APP_URL ?? ''}/${portSlug}`; const crmUrl = `${process.env.APP_URL ?? ''}/${portSlug}`;
@@ -116,6 +139,10 @@ export async function sendWebsiteSubmissionEmails(
undefined, undefined,
confirmation.text, confirmation.text,
portId, portId,
undefined,
undefined,
undefined,
contactEmail,
); );
} }
@@ -172,6 +199,10 @@ export async function sendWebsiteSubmissionEmails(
undefined, undefined,
confirmation.text, confirmation.text,
portId, portId,
undefined,
undefined,
undefined,
contactEmail,
); );
} }
@@ -186,7 +217,6 @@ export async function sendWebsiteSubmissionEmails(
preferredContactMethod: fields.preferredContact ?? undefined, preferredContactMethod: fields.preferredContact ?? undefined,
placeOfResidence: fields.placeOfResidence ?? undefined, placeOfResidence: fields.placeOfResidence ?? undefined,
notes: fields.comments ?? undefined, notes: fields.comments ?? undefined,
crmDeepLink: crmUrl,
portName, portName,
}, },
{ branding }, { branding },
@@ -217,7 +247,18 @@ export async function sendWebsiteSubmissionEmails(
fallback: confirmation.subject, fallback: confirmation.subject,
tokens: { portName, recipientName: fields.firstName }, 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 // 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. */ * password. When false, `password` must be supplied inline. */
sendSetupEmail: z.boolean().optional(), sendSetupEmail: z.boolean().optional(),
displayName: z.string().min(1).max(200), 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(), firstName: z.string().min(1).max(200).nullable().optional(),
lastName: z.string().min(1).max(200).nullable().optional(), lastName: z.string().min(1).max(200).nullable().optional(),
phone: z.string().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'; } from '@/lib/email/templates/residential-inquiry';
describe('residentialClientConfirmation', () => { describe('residentialClientConfirmation', () => {
it('reflects the chosen residence types in the thank-you copy', async () => { it('mirrors the website copy + reflects the chosen residence types', async () => {
const { html, text } = await residentialClientConfirmation({ const { subject, html, text } = await residentialClientConfirmation({
firstName: 'Mia', firstName: 'Mia',
contactEmail: 'sales@portnimara.com', contactEmail: 'sales@portnimara.com',
residenceTypes: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'], residenceTypes: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'],
portName: 'Port Nimara', portName: 'Port Nimara',
}); });
expect(html).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa'); expect(subject).toBe('Port Nimara — Thank You for Your Interest');
expect(text).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa'); expect(text).toContain(
expect(html).toContain('Mia'); '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({ const { html } = await residentialClientConfirmation({
firstName: 'Sam', firstName: 'Sam',
contactEmail: 'sales@portnimara.com', contactEmail: 'sales@portnimara.com',
portName: 'Port Nimara', 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', () => { 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({ const { html, text } = await residentialSalesAlert({
fullName: 'Mia Ng', fullName: 'Mia Ng',
email: 'mia@example.com', email: 'mia@example.com',
@@ -37,21 +43,22 @@ describe('residentialSalesAlert', () => {
residenceTypes: ['Two Bedroom Marina Villa'], residenceTypes: ['Two Bedroom Marina Villa'],
preferredContactMethod: 'phone', preferredContactMethod: 'phone',
notes: 'Looking for a winter completion.', notes: 'Looking for a winter completion.',
crmDeepLink: 'https://crm.portnimara.com/port-nimara',
portName: '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('A new residential enquiry has come in');
expect(html).toContain('Residence type(s):'); expect(html).toContain('Residence type(s):');
expect(html).toContain('Two Bedroom Marina Villa'); expect(html).toContain('Two Bedroom Marina Villa');
expect(html).toContain('Preferred contact:'); expect(html).toContain('Preferred contact:');
expect(html).toContain('Phone call back'); expect(html).toContain('Phone call back');
expect(html).toContain('Looking for a winter completion.'); expect(html).toContain('Looking for a winter completion.');
expect(html).toContain('to follow up'); expect(text).toContain('- Port Nimara Residences');
// Plain-text part mirrors the other alerts. // 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('Residence type(s): Two Bedroom Marina Villa');
expect(text).toContain('Preferred contact: Phone call back'); expect(text).toContain('Preferred contact: Phone call back');
expect(text).toContain('Comments: Looking for a winter completion.'); expect(text).toContain('Comments: Looking for a winter completion.');
expect(text).not.toContain('CRM');
}); });
it('omits optional rows cleanly when absent', async () => { it('omits optional rows cleanly when absent', async () => {