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

@@ -35,8 +35,18 @@ const SEGMENT_LABELS: Record<string, string> = {
profile: 'Profile',
};
// UUID v4-ish (or any 36-char hex+dash) — used to skip entity-id segments
// from the breadcrumbs since the page H1 already shows the entity name.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function isIdSegment(segment: string): boolean {
return UUID_RE.test(segment);
}
function formatSegment(segment: string): string {
return SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return (
SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
);
}
export function Breadcrumbs() {
@@ -46,10 +56,11 @@ export function Breadcrumbs() {
// Split pathname and filter empty segments
const rawSegments = pathname.split('/').filter(Boolean);
// Remove the portSlug segment from display
const segments = currentPortSlug
? rawSegments.filter((seg) => seg !== currentPortSlug)
: rawSegments;
// Remove the portSlug segment and any UUID-ish entity-id segments — the
// page H1 already shows the entity name, no need to leak the raw id.
const segments = (
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
).filter((seg) => !isIdSegment(seg));
if (segments.length === 0) {
return (

View File

@@ -17,6 +17,7 @@ import {
Bell,
Settings,
Shield,
Home,
ChevronLeft,
ChevronRight,
Menu,
@@ -38,6 +39,7 @@ import type { Role } from '@/lib/db/schema/users';
interface SidebarProps {
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
isSuperAdmin?: boolean;
}
interface NavItem {
@@ -51,6 +53,10 @@ interface NavSection {
title: string;
items: NavItem[];
adminRequired?: boolean;
/** When true, only render if the user has marina-side access. */
marinaRequired?: boolean;
/** When true, only render if the user has residential-side access. */
residentialRequired?: boolean;
}
function buildNavSections(portSlug: string | undefined): NavSection[] {
@@ -59,6 +65,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
return [
{
title: 'Main',
marinaRequired: true,
items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/clients`, label: 'Clients', icon: Users },
@@ -68,8 +75,25 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
],
},
{
title: 'Residential',
residentialRequired: true,
items: [
{
href: `${base}/residential/clients`,
label: 'Residential Clients',
icon: Home,
},
{
href: `${base}/residential/interests`,
label: 'Residential Interests',
icon: Bookmark,
},
],
},
{
title: 'Documents',
marinaRequired: true,
items: [
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
@@ -77,6 +101,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
},
{
title: 'Financial',
marinaRequired: true,
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
@@ -84,6 +109,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
},
{
title: 'Communication',
marinaRequired: true,
items: [
{ href: `${base}/email`, label: 'Email', icon: Mail },
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
@@ -150,11 +176,15 @@ function SidebarContent({
portSlug,
portRoles,
hasAdminAccess,
hasMarinaAccess,
hasResidentialAccess,
}: {
collapsed: boolean;
portSlug: string | undefined;
portRoles: SidebarProps['portRoles'];
hasAdminAccess: boolean;
hasMarinaAccess: boolean;
hasResidentialAccess: boolean;
}) {
const pathname = usePathname();
const [adminExpanded, setAdminExpanded] = useState(false);
@@ -191,6 +221,8 @@ function SidebarContent({
<nav className="px-2 space-y-4">
{sections.map((section) => {
if (section.adminRequired && !hasAdminAccess) return null;
if (section.marinaRequired && !hasMarinaAccess) return null;
if (section.residentialRequired && !hasResidentialAccess) return null;
return (
<div key={section.title}>
@@ -272,16 +304,25 @@ function SidebarContent({
);
}
export function Sidebar({ portRoles }: SidebarProps) {
export function Sidebar({ portRoles, isSuperAdmin = false }: SidebarProps) {
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
// Check for admin access based on role permissions
const hasAdminAccess = portRoles.some(
(pr) =>
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
);
// Super admins see every section regardless of role rows.
const hasAdminAccess =
isSuperAdmin ||
portRoles.some(
(pr) =>
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
);
const hasMarinaAccess =
isSuperAdmin || portRoles.some((pr) => pr.role?.permissions?.clients?.view);
const hasResidentialAccess =
isSuperAdmin ||
portRoles.some((pr) => pr.residentialAccess || pr.role?.permissions?.residential_clients?.view);
return (
<>
@@ -298,6 +339,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
/>
{/* Collapse toggle */}
@@ -337,6 +380,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
/>
</SheetContent>
</Sheet>

View File

@@ -1,6 +1,6 @@
'use client';
import { Plus, Moon, Sun, LogOut, User, Settings } from 'lucide-react';
import { Plus, Moon, Sun, LogOut, User, Settings, Bell } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useUIStore } from '@/stores/ui-store';
@@ -113,6 +113,13 @@ export function Topbar({ ports }: TopbarProps) {
<Settings className="w-4 h-4 mr-2" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick={() => router.push(`${base}/notifications/preferences` as any)}
>
<Bell className="w-4 h-4 mr-2" />
Notification preferences
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleToggleDarkMode}>
{darkMode ? (