feat(platform): residential module + admin UI + reliability fixes
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped

Residential platform
- New schema: residentialClients, residentialInterests (separate from
  marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint

Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)

Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
  handlers.ts files (Next.js 15 route.ts only allows specific exports)

Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
  (apiFetch already JSON.stringifies its body; passing a stringified
  body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
  to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
  Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md

Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-27 21:54:32 +02:00
parent fac8021156
commit e8d61c91c4
121 changed files with 34105 additions and 1016 deletions

View File

@@ -0,0 +1,101 @@
interface InviteData {
link: string;
ttlHours: number;
recipientName?: string;
isSuperAdmin: boolean;
}
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
function shell(opts: { title: string; body: string }): string {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${opts.title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export function crmInviteEmail(data: InviteData): {
subject: string;
html: string;
text: string;
} {
const subject = `You're invited to the Port Nimara CRM`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Welcome to the Port Nimara CRM
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
You've been invited to the Port Nimara CRM as a ${role}. Click the
button below to set your password and activate your account. The
link expires in ${data.ttlHours} hours.
</p>
<p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Set up your account
</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If the button doesn't work, paste this link into your browser:<br />
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>Port Nimara CRM</strong>
</p>`;
const text = [
`Welcome to the Port Nimara CRM`,
'',
`You've been invited as a ${role}.`,
`Set up your account: ${data.link}`,
'',
`The link expires in ${data.ttlHours} hours.`,
'',
`Thank you,`,
`Port Nimara CRM`,
].join('\n');
return { subject, html: shell({ title: subject, body }), text };
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -0,0 +1,107 @@
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
function shell(opts: { title: string; body: string }): string {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${opts.title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export interface ResidentialClientConfirmationData {
firstName: string;
contactEmail: string;
}
export function residentialClientConfirmation(data: ResidentialClientConfirmationData) {
const subject = 'Thank You for Your Interest — Port Nimara Residences';
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Welcome to Port Nimara
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
Dear ${escapeHtml(data.firstName)},
</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
Thank you for expressing interest in Port Nimara residences. Our residential
sales team has received your inquiry and will reach out to you shortly with
more information.
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
If you have any questions in the meantime, please reach us at
<a href="mailto:${escapeHtml(data.contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
</p>
<p style="font-size:16px; margin-top:30px;">
Best regards,<br />
<strong>The Port Nimara Residential Team</strong>
</p>`;
return { subject, html: shell({ title: subject, body }) };
}
export interface ResidentialSalesAlertData {
fullName: string;
email: string;
phone: string;
placeOfResidence?: string;
preferredContactMethod?: 'email' | 'phone';
notes?: string;
preferences?: string;
crmDeepLink?: string;
}
export function residentialSalesAlert(data: ResidentialSalesAlertData) {
const subject = `New Residential Inquiry — ${data.fullName}`;
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
New residential inquiry
</p>
<table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;">
<tr><td style="color:#666; width:140px;">Name</td><td>${escapeHtml(data.fullName)}</td></tr>
<tr><td style="color:#666;">Email</td><td>${escapeHtml(data.email)}</td></tr>
<tr><td style="color:#666;">Phone</td><td>${escapeHtml(data.phone)}</td></tr>
${data.placeOfResidence ? `<tr><td style="color:#666;">Residence</td><td>${escapeHtml(data.placeOfResidence)}</td></tr>` : ''}
${data.preferredContactMethod ? `<tr><td style="color:#666;">Prefers</td><td>${escapeHtml(data.preferredContactMethod)}</td></tr>` : ''}
${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''}
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
</table>
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${data.crmDeepLink}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
<p style="font-size:14px; color:#666;">— Port Nimara CRM</p>`;
return { subject, html: shell({ title: subject, body }) };
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}