# L1 — Core CRUD (Competing Plan — Claude Code) **Duration:** Days 10–14 (5 days) **Parallelism:** 3 Git worktrees (Streams A, B, C run simultaneously) **Depends on:** Layer 0 complete **References:** `06-MASTER-FEATURE-SPEC.md`, `07-DATABASE-SCHEMA.md`, `08-API-ENDPOINT-CATALOG.md`, `09-BUSINESS-RULES.md`, `10-AUTH-AND-PERMISSIONS.md`, `13-UI-PAGE-MAP.md` --- ## 1. Baseline Critique ### What the baseline gets right - **3-stream parallelization** (Clients / Berths / Auth Admin) is correct and matches `12-IMPLEMENTATION-SEQUENCE.md`. - **Service pattern** (portId first param, audit log, socket event) is solid. - **Merge order** (auth admin first, then clients, then berths) is the correct dependency chain. - **Duplicate detection scope** (exact email + fuzzy name/phone) is well-defined. - **Client merge transaction list** covers all child entities. ### What's missing or wrong 1. **Route paths still wrong.** Uses `(crm)/` throughout. Must be `(dashboard)/[portSlug]/` per locked file organization. Every file path referencing `(crm)` is wrong. 2. **Permission structure doesn't match spec.** The baseline invents its own permission keys (`list`, `view`, `create`, `update`, `delete`). The spec in `10-AUTH-AND-PERMISSIONS.md` Section 2.2 uses different keys: `view`, `create`, `edit` (not `update`), `delete`, `merge`, `export`, `change_stage`, `generate_eoi`, `scan_receipt`, `send_for_signing`, etc. The baseline's simplified permission model breaks when Layer 2+ features need granular permissions like `interests.change_stage`. 3. **API route pattern contradicts L0 design.** The baseline uses `withAuth(request)` inside a try/catch in every handler. My L0 plan defines `withAuth` as a composable wrapper function. L1 should use the L0 pattern: `export const GET = withAuth(withPermission('clients', 'view', handler))` — zero boilerplate per route. 4. **Component paths use invented `domain/` directory.** The locked structure uses `src/components/clients/`, `src/components/berths/`, etc. — not `src/components/domain/clients/`. Small detail, wrong all 5 days. 5. **Berth explorer layout is flat.** The spec in `13-UI-PAGE-MAP.md` defines a **three-panel layout** (map panel + smart list panel + detail panel). The baseline treats it as a standard list + detail page and adds the map as a separate component without the integrated three-panel UX. 6. **Missing saved views.** Both client and berth list pages should support saved filter configurations per `13-UI-PAGE-MAP.md` — save current filters, dropdown to load saved views, manage (rename/delete/share) views. The `saved_views` table exists in the schema. Not mentioned in baseline. 7. **Missing bulk operations.** Spec requires bulk tag, bulk export (CSV/PDF), bulk archive on client list. Bulk action toolbar appears when rows are selected. Not in baseline. 8. **Client form missing vessel details and proxy/representative sections.** The create client form spec in `13-UI-PAGE-MAP.md` includes yacht name, length, width, draft (dual unit input), berth size desired, proxy toggle, proxy type, actual owner name. The baseline's Zod schema only mentions "name, email, nationality, source, company, notes, proxy fields" without specifying the vessel section. 9. **No `pg_trgm` extension setup** for fuzzy duplicate detection. Levenshtein distance on every create is expensive without an index. Need `CREATE EXTENSION pg_trgm` and a GiST index on `clients.name`. 10. **Missing public interest registration endpoint.** `POST /api/public/interests` is listed in the public API spec. The baseline only covers public berth GET endpoints. Website interest registration (create client + interest in one call) is a key integration point. 11. **No mention of email for user creation.** The baseline says "create Better Auth user + generate password-set token + send email" but doesn't specify the MJML template, the Nodemailer integration, or what happens when Poste.io is unreachable. 12. **Berth maintenance log photos** need MinIO presigned upload URLs, not direct upload. The security guidelines require UUID-based storage paths and presigned URLs. The baseline mentions "photo upload to MinIO via presigned URL" but doesn't specify the upload flow. --- ## 2. Implementation Plan ### Pre-Stream Setup (First Hour of Day 1) Before any stream starts, establish shared patterns as reusable utilities: **File:** `src/lib/api/route-helpers.ts` — extends L0's `helpers.ts` with: ```typescript import { type NextRequest, NextResponse } from 'next/server'; import type { ZodSchema } from 'zod'; import { withAuth, type AuthContext } from './helpers'; import { checkRateLimit, rateLimiters, rateLimitHeaders } from '@/lib/rate-limit'; /** * Parse and validate query params against a Zod schema. */ export function parseQuery(req: NextRequest, schema: ZodSchema): T { const raw = Object.fromEntries(req.nextUrl.searchParams); return schema.parse(raw); } /** * Parse and validate JSON body against a Zod schema. */ export async function parseBody(req: NextRequest, schema: ZodSchema): Promise { const raw = await req.json(); return schema.parse(raw); } /** * Standard paginated list response shape. */ export interface PaginatedResponse { data: T[]; pagination: { page: number; limit: number; total: number; totalPages: number; }; } /** * Standard list query schema (reused across all entities). */ export const baseListQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(25), sortBy: z.string().optional(), sortOrder: z.enum(['asc', 'desc']).default('desc'), search: z.string().max(200).optional(), archived: z .enum(['true', 'false']) .default('false') .transform((v) => v === 'true'), }); ``` **File:** `src/lib/services/base-service.ts` — common service patterns: ```typescript import { eq, and, ilike, sql, type SQL } from 'drizzle-orm'; import type { PgTable } from 'drizzle-orm/pg-core'; import { db } from '@/lib/db'; import { createAuditLog, diffFields } from '@/lib/audit'; import { emitToRoom } from '@/lib/socket/server'; /** * Port-scoped count + paginate helper. */ export async function paginatedQuery( table: PgTable, portId: string, options: { page: number; limit: number; where?: SQL[]; orderBy?: SQL; includeArchived?: boolean; }, ): Promise<{ data: T[]; total: number }> { const conditions: SQL[] = [eq(table.portId, portId)]; if (!options.includeArchived && 'archivedAt' in table) { conditions.push(sql`${table.archivedAt} IS NULL`); } if (options.where) conditions.push(...options.where); const [countResult] = await db .select({ count: sql`count(*)` }) .from(table) .where(and(...conditions)); const data = (await db .select() .from(table) .where(and(...conditions)) .orderBy(options.orderBy ?? sql`${table.createdAt} DESC`) .limit(options.limit) .offset((options.page - 1) * options.limit)) as T[]; return { data, total: Number(countResult?.count ?? 0) }; } ``` **File:** `src/components/shared/data-table.tsx` — reusable TanStack Table wrapper: ```typescript interface DataTableProps { columns: ColumnDef[]; data: TData[]; pagination: PaginatedResponse['pagination']; onPaginationChange: (page: number) => void; onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void; onRowSelect?: (rows: TData[]) => void; isLoading?: boolean; emptyState?: ReactNode; bulkActions?: ReactNode; } ``` **File:** `src/components/shared/entity-filters.tsx` — composable filter bar: ```typescript interface FilterConfig { key: string; label: string; type: 'search' | 'select' | 'multi-select' | 'date-range' | 'number-range'; options?: { label: string; value: string }[]; } ``` **File:** `src/hooks/use-entity-list.ts` — TanStack Query hook for any entity list: ```typescript export function useEntityList( entityKey: string, portId: string, params: Record, options?: { enabled?: boolean }, ) { return useQuery>({ queryKey: [entityKey, portId, params], queryFn: () => fetchAPI(`/api/v1/${entityKey}`, { params }), ...options, }); } ``` --- ### Stream A: Client Management (5 days) #### Day 1: Validators + Service + API + List Page **Zod Schemas:** **File:** `src/lib/validators/clients.ts` ```typescript import { z } from 'zod'; import { CLIENT_SOURCES } from '@/lib/constants'; export const createClientSchema = z.object({ fullName: z.string().min(1).max(200), company: z.string().max(200).optional(), nationality: z.string().max(100).optional(), source: z.enum(CLIENT_SOURCES), referredById: z.string().uuid().optional(), // Vessel details (from 13-UI-PAGE-MAP.md) vesselName: z.string().max(200).optional(), vesselLengthM: z.number().positive().optional(), vesselWidthM: z.number().positive().optional(), vesselDraftM: z.number().positive().optional(), desiredBerthSize: z.string().max(50).optional(), // Proxy/representative isProxy: z.boolean().default(false), proxyType: z.enum(['broker', 'attorney', 'family_office', 'assistant', 'other']).optional(), actualOwnerName: z.string().max(200).optional(), proxyRelationshipNotes: z.string().max(1000).optional(), // Communication preferredContactMethod: z.enum(['email', 'phone', 'whatsapp']).optional(), preferredLanguage: z.string().max(10).default('en'), timezone: z.string().max(50).optional(), // Initial contact (at least one required — validated at service level) contacts: z .array( z.object({ channel: z.enum(['email', 'phone', 'whatsapp', 'other']), value: z.string().min(1).max(255), label: z.string().max(100).optional(), isPrimary: z.boolean().default(false), }), ) .min(1, 'At least one contact method is required'), // Tags tagIds: z.array(z.string().uuid()).optional(), // Initial note initialNote: z.string().max(5000).optional(), }); export const updateClientSchema = createClientSchema .partial() .omit({ contacts: true, tagIds: true, initialNote: true }); export const clientListQuerySchema = baseListQuerySchema.extend({ nationality: z.string().optional(), source: z.enum(CLIENT_SOURCES).optional(), tagId: z.string().uuid().optional(), hasInterests: z.enum(['true', 'false']).optional(), }); export type CreateClientInput = z.infer; export type UpdateClientInput = z.infer; export type ClientListQuery = z.infer; ``` **Service Layer:** **File:** `src/lib/services/clients.ts` ```typescript export const clientService = { async list(portId: string, query: ClientListQuery): Promise>, async getById(portId: string, id: string): Promise, async create(portId: string, userId: string, data: CreateClientInput, meta: AuditMeta): Promise, async update(portId: string, userId: string, id: string, data: UpdateClientInput, meta: AuditMeta): Promise, async archive(portId: string, userId: string, id: string, meta: AuditMeta): Promise, async restore(portId: string, userId: string, id: string, meta: AuditMeta): Promise, async exportPdf(portId: string, id: string): Promise, // Stub — implemented in L4 }; ``` Every function: 1. Scopes by `portId` with `where(eq(clients.portId, portId))` 2. Returns only non-archived records by default 3. Calls `createAuditLog()` with `diffFields()` for updates 4. Emits socket event to `port:{portId}` room 5. For `create`: runs duplicate detection before insert (see Day 4) **API Routes:** **File:** `src/app/api/v1/clients/route.ts` ```typescript export const GET = withAuth( withPermission('clients', 'view', async (req, ctx) => { const query = parseQuery(req, clientListQuerySchema); const result = await clientService.list(ctx.portId, query); return NextResponse.json(result); }), ); export const POST = withAuth( withPermission('clients', 'create', async (req, ctx) => { const body = await parseBody(req, createClientSchema); const duplicates = await duplicateService.checkOnCreate(ctx.portId, body); if (duplicates.length > 0 && !req.headers.get('X-Force-Create')) { return NextResponse.json({ duplicates, requiresConfirmation: true }, { status: 409 }); } const client = await clientService.create(ctx.portId, ctx.userId, body, { ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }); return NextResponse.json(client, { status: 201 }); }), ); ``` **File:** `src/app/api/v1/clients/[id]/route.ts` — GET, PATCH, DELETE (archive) **UI Components:** **File:** `src/app/(dashboard)/[portSlug]/clients/page.tsx` — server component shell **File:** `src/components/clients/client-list.tsx` — client component with TanStack Query, DataTable, filters **File:** `src/components/clients/client-columns.tsx` — column definitions **File:** `src/components/clients/client-filters.tsx` — filter bar (search, nationality, source, tags, archived toggle) **File:** `src/components/clients/client-form.tsx` — create/edit form with all sections from `13-UI-PAGE-MAP.md` shadcn components used: `DataTable`, `Form`, `Input`, `Select`, `Textarea`, `Checkbox`, `Badge`, `Button`, `Card`, `Tabs`, `DropdownMenu` TanStack Query keys: `['clients', portId, { page, limit, search, filters... }]` #### Day 2: Detail Page + Contacts + Relationships **File:** `src/app/(dashboard)/[portSlug]/clients/[id]/page.tsx` — detail page with tabs **File:** `src/components/clients/client-detail-header.tsx` — name, company, tags, status, quick actions **File:** `src/components/clients/client-tabs.tsx` — tab container (Overview, Relationships, Interests, Activity, Notes, Files, Emails, Documents, Invoices, Audit Trail) **Contacts Service:** **File:** `src/lib/services/client-contacts.ts` ```typescript export const contactService = { async listForClient(portId: string, clientId: string): Promise, async create(portId: string, userId: string, clientId: string, data: CreateContactInput, meta: AuditMeta): Promise, async update(portId: string, userId: string, contactId: string, data: UpdateContactInput, meta: AuditMeta): Promise, async delete(portId: string, userId: string, contactId: string, meta: AuditMeta): Promise, async setPrimary(portId: string, userId: string, clientId: string, contactId: string, meta: AuditMeta): Promise, }; ``` **Relationships Service:** **File:** `src/lib/services/client-relationships.ts` ```typescript export const relationshipService = { async listForClient(portId: string, clientId: string): Promise, async create(portId: string, userId: string, data: CreateRelationshipInput, meta: AuditMeta): Promise, // Creates bidirectional: A→B and B→A with inverse relationship types async delete(portId: string, userId: string, relationshipId: string, meta: AuditMeta): Promise, // Deletes both directions }; ``` Relationship types: `referral`, `broker`, `family`, `same_vessel`, `business_partner`, `custom` Inverse mappings: `referral` ↔ `referred_by`, `broker` ↔ `broker_client`, others are symmetric **UI:** **File:** `src/components/clients/contact-list.tsx` — inline add/edit/remove contacts **File:** `src/components/clients/relationship-list.tsx` — linked clients, type badges, navigate-to **File:** `src/components/clients/client-overview-tab.tsx` — core fields, vessel details, proxy info, contacts #### Day 3: Notes + Tags + Saved Views **Notes Service:** **File:** `src/lib/services/notes.ts` — shared across clients and interests ```typescript export const noteService = { async listForEntity(portId: string, entityType: 'client' | 'interest', entityId: string, query: NoteListQuery): Promise>, async create(portId: string, userId: string, entityType: string, entityId: string, data: CreateNoteInput, meta: AuditMeta): Promise, async update(portId: string, userId: string, noteId: string, data: UpdateNoteInput, meta: AuditMeta): Promise, // Enforces 15-minute edit window: throws if createdAt + 15min < now async delete(portId: string, userId: string, noteId: string, meta: AuditMeta): Promise, }; ``` @mention format: `[[@userId:displayName]]` — stored as tokens, rendered as styled chips in UI. **Tags Service:** **File:** `src/lib/services/tags.ts` — shared across entities ```typescript export const tagService = { async list(portId: string, entityType?: string): Promise, async create(portId: string, userId: string, data: { name: string; color: string; entityType: string }, meta: AuditMeta): Promise, async update(portId: string, userId: string, tagId: string, data: { name?: string; color?: string }, meta: AuditMeta): Promise, async delete(portId: string, userId: string, tagId: string, meta: AuditMeta): Promise, async assignToEntity(portId: string, userId: string, entityType: string, entityId: string, tagId: string, meta: AuditMeta): Promise, async removeFromEntity(portId: string, userId: string, entityType: string, entityId: string, tagId: string, meta: AuditMeta): Promise, }; ``` **Saved Views Service:** **File:** `src/lib/services/saved-views.ts` ```typescript export const savedViewService = { async list(portId: string, userId: string, entityType: string): Promise, async create(portId: string, userId: string, data: CreateSavedViewInput): Promise, async update(portId: string, userId: string, viewId: string, data: UpdateSavedViewInput): Promise, async delete(portId: string, userId: string, viewId: string): Promise, }; ``` Saved views store: filter state as JSONB, name, entity type, shared flag, created by. **UI:** **File:** `src/components/clients/notes-timeline.tsx` — reverse-chronological thread **File:** `src/components/clients/note-editor.tsx` — lightweight TipTap (bold, italic, lists, @mentions) **File:** `src/components/shared/tag-picker.tsx` — multi-select combobox with color dots **File:** `src/components/shared/tag-badge.tsx` — colored tag display **File:** `src/components/shared/saved-views-dropdown.tsx` — dropdown + save/manage modal #### Day 4: Duplicate Detection + Merge + Bulk Actions **Duplicate Detection:** **File:** `src/lib/services/client-duplicates.ts` ```typescript export const duplicateService = { async checkOnCreate(portId: string, data: CreateClientInput): Promise, // 1. Exact email match (any contact) → high confidence // 2. Fuzzy name match (pg_trgm similarity > 0.6) → medium confidence // 3. Phone suffix match (last 8 digits) → medium confidence async getMergeCandidates(portId: string, clientId: string): Promise, }; interface DuplicateCandidate { clientId: string; clientName: string; matchType: 'exact_email' | 'fuzzy_name' | 'phone_suffix'; confidence: 'high' | 'medium'; matchedField: string; matchedValue: string; } ``` Requires `pg_trgm` extension — add to `docker/postgres/init.sql`: ```sql CREATE EXTENSION IF NOT EXISTS "pg_trgm"; ``` Add GiST index: `CREATE INDEX idx_clients_name_trgm ON clients USING gist (full_name gist_trgm_ops);` **Client Merge:** **File:** `src/lib/services/client-merge.ts` ```typescript export const mergeService = { async merge(portId: string, userId: string, masterId: string, duplicateId: string, meta: AuditMeta): Promise, // Transaction: // 1. Verify both clients exist and belong to portId // 2. Re-parent contacts (skip duplicates by channel+value) // 3. Re-parent notes (preserve original author + timestamps) // 4. Re-parent relationships (skip self-referential) // 5. Re-parent interests (update client_id FK) // 6. Re-parent expenses (update client_id FK) // 7. Re-parent invoices (update client_id FK) // 8. Merge tags (union, skip duplicates) // 9. Re-parent files (update entity references) // 10. Create client_merge_log entry // 11. Archive duplicate client // 12. Audit log both clients // 13. Emit client:merged socket event }; ``` **Bulk Operations:** **File:** `src/lib/services/bulk-operations.ts` ```typescript export const bulkService = { async bulkTag(portId: string, userId: string, entityType: string, entityIds: string[], tagId: string, meta: AuditMeta): Promise<{ success: number; failed: number }>, async bulkArchive(portId: string, userId: string, entityType: string, entityIds: string[], meta: AuditMeta): Promise<{ success: number; failed: number }>, async bulkExportCsv(portId: string, entityType: string, entityIds: string[]): Promise, }; ``` **API Routes:** - `POST /api/v1/clients/merge` — merge two clients - `POST /api/v1/clients/bulk/tag` — bulk tag - `POST /api/v1/clients/bulk/archive` — bulk archive - `POST /api/v1/clients/bulk/export` — bulk CSV export **UI:** **File:** `src/components/clients/duplicate-alert-dialog.tsx` — shown during create, side-by-side comparison **File:** `src/components/clients/merge-dialog.tsx` — field-by-field selection, preview, confirm **File:** `src/components/shared/bulk-action-bar.tsx` — appears when rows selected, context-aware actions #### Day 5: Activity Timeline + Audit Trail + Integration **File:** `src/lib/services/activity.ts` ```typescript export const activityService = { async getForEntity(portId: string, entityType: string, entityId: string, query: ActivityQuery): Promise>, async getRecentForPort(portId: string, query: ActivityQuery): Promise>, // Aggregates from audit_logs, notes, and status changes into a unified timeline }; ``` **File:** `src/components/clients/activity-timeline.tsx` — chronological feed with icons per action type **File:** `src/components/clients/audit-trail-tab.tsx` — raw audit log with JSON diff viewer **File:** `src/components/shared/json-diff.tsx` — before/after value display with highlighting **API Routes:** - `GET /api/v1/clients/[id]/activity` — activity feed - `GET /api/v1/clients/[id]/audit` — raw audit log - `GET /api/v1/clients/[id]/notes` — notes - `POST /api/v1/clients/[id]/notes` — create note - `GET /api/v1/clients/[id]/contacts` — contacts - `POST /api/v1/clients/[id]/contacts` — add contact - `GET /api/v1/clients/[id]/relationships` — relationships - `POST /api/v1/clients/[id]/relationships` — add relationship - `GET /api/v1/clients/[id]/tags` — tags - `POST /api/v1/clients/[id]/tags` — assign tag - `DELETE /api/v1/clients/[id]/tags/[tagId]` — remove tag --- ### Stream B: Berth Management (5 days) #### Day 1: Validators + Service + API + Three-Panel Explorer **Zod Schemas:** **File:** `src/lib/validators/berths.ts` ```typescript export const createBerthSchema = z.object({ berthNumber: z.string().min(1).max(20), area: z.string().max(100).optional(), status: z.enum(BERTH_STATUSES).default('available'), // Dimensions (metric — imperial derived) nominalLengthM: z.number().positive().optional(), nominalWidthM: z.number().positive().optional(), maxLengthM: z.number().positive().optional(), maxWidthM: z.number().positive().optional(), maxDraftM: z.number().positive().optional(), // Infrastructure powerSupply: z.string().max(100).optional(), waterSupply: z.boolean().optional(), fuelAccess: z.boolean().optional(), wasteDisposal: z.boolean().optional(), securityLevel: z.string().max(50).optional(), accessType: z.string().max(100).optional(), // Commercial askingPriceUsd: z.number().nonneg().optional(), annualFeeUsd: z.number().nonneg().optional(), maintenanceFeeUsd: z.number().nonneg().optional(), // Tenure tenureType: z.enum(['permanent', 'fixed_term']).optional(), tenureStartDate: z.string().datetime().optional(), tenureEndDate: z.string().datetime().optional(), tenureStatus: z.enum(['active', 'expiring', 'expired', 'available']).optional(), // Description description: z.string().max(5000).optional(), internalNotes: z.string().max(5000).optional(), }); export const berthListQuerySchema = baseListQuerySchema.extend({ area: z.string().optional(), status: z.enum(BERTH_STATUSES).optional(), sizeMin: z.coerce.number().optional(), sizeMax: z.coerce.number().optional(), priceMin: z.coerce.number().optional(), priceMax: z.coerce.number().optional(), }); ``` **Key UI Difference from Baseline — Three-Panel Explorer:** **File:** `src/app/(dashboard)/[portSlug]/berths/page.tsx` **File:** `src/components/berths/berth-explorer.tsx` — three-panel layout per `13-UI-PAGE-MAP.md`: ``` ┌────────────────────────────────────────────────┐ │ [Interactive Berth Map] [Collapse ▲] │ │ SVG map, status-colored, clickable │ ├──────────────┬─────────────────────────────────┤ │ Smart List │ Detail Panel │ │ ────────── │ ───────────── │ │ Grouped by │ Full berth specs │ │ status │ (collapsible sections) │ │ Filterable │ Linked interests │ │ │ Waiting list │ │ [+ New] │ Maintenance log │ │ │ [Edit] [Compare] [Export] │ └──────────────┴─────────────────────────────────┘ ``` Panel behavior: - Map panel: collapsible (stores preference in Zustand), SVG with `berth_map_data` coordinates - List panel: grouped by status (Under Offer → Available → Sold), search/filter - Detail panel: rendered when a berth is selected (from map click or list click) - URL state: `/berths?selected={berthId}` — shareable/bookmarkable **File:** `src/components/berths/berth-map.tsx` — interactive SVG component **File:** `src/components/berths/berth-smart-list.tsx` — grouped list with filters **File:** `src/components/berths/berth-detail-panel.tsx` — right panel with collapsible sections #### Day 2: Map + Status Colors + Spec Sheet **Map Implementation:** ```typescript // Color mapping per berth status const STATUS_COLORS: Record = { available: 'var(--success)', // green under_offer: 'var(--warning)', // amber sold: 'var(--error)', // red maintenance: 'var(--muted)', // gray reserved: 'var(--chart-4)', // purple }; ``` Map features: - SVG container with zoom/pan (CSS transform, no heavy library) - Berth shapes positioned by `berth_map_data.svg_x`, `svg_y`, `svg_width`, `svg_height` - Hover: tooltip with berth number, status, dimensions - Click: select berth → updates detail panel + URL state - Real-time: Socket.io `berth:statusChanged` → re-colors berth on map without refresh **Spec Sheet:** **File:** `src/components/berths/berth-spec-sheet.tsx` Collapsible sections: 1. **Dimensions** — nominal/max length, width, draft (dual unit: meters + feet) 2. **Infrastructure** — power, water, fuel, waste, security, access 3. **Commercial** — asking price, annual fee, maintenance fee (formatted with currency) 4. **Tenure** — type, dates, status with color indicator 5. **Description** — full text + internal notes (admin only) Dual unit display utility: ```typescript export function formatDualUnit(meters: number | null): string { if (meters == null) return '—'; return `${meters.toFixed(1)}m / ${toFeet(meters).toFixed(1)}ft`; } ``` #### Day 3: Tags + Comparison + Tenure Indicators **File:** `src/app/(dashboard)/[portSlug]/berths/compare/page.tsx` **File:** `src/components/berths/berth-comparison.tsx` - Select 2-3 berths via URL params `?ids=uuid1,uuid2,uuid3` - Side-by-side table: rows = spec fields, columns = berths - Highlight differences with background color - "Export PDF" button (stub — implemented in L4) - shadcn components: `Table`, `Badge`, `Button` Tenure visual indicators (integrated into list + detail, not separate page): - Green badge: active, >6 months remaining - Amber badge: expiring within 6 months - Red badge: expired - Calculated from `tenure_end_date` vs `now()` #### Day 4: Maintenance Log + Photos + Gallery **File:** `src/lib/services/berth-maintenance.ts` ```typescript export const maintenanceService = { async list(portId: string, berthId: string, query: MaintenanceQuery): Promise>, async create(portId: string, userId: string, berthId: string, data: CreateMaintenanceInput, meta: AuditMeta): Promise, async update(portId: string, userId: string, entryId: string, data: UpdateMaintenanceInput, meta: AuditMeta): Promise, async delete(portId: string, userId: string, entryId: string, meta: AuditMeta): Promise, async getUploadUrl(portId: string, berthId: string, entryId: string, filename: string): Promise<{ uploadUrl: string; objectKey: string }>, // Returns presigned PUT URL for direct browser → MinIO upload }; ``` Photo upload flow: 1. Client requests presigned upload URL from API 2. API generates UUID-based object key: `{portSlug}/berths/{berthId}/maintenance/{entryId}/{uuid}.{ext}` 3. Client uploads directly to MinIO via presigned PUT URL 4. Client notifies API of successful upload → creates `files` table entry 5. Photos stored with metadata in `files` table, linked to maintenance entry **File:** `src/components/berths/maintenance-log.tsx` — entry list with type icons, dates, description **File:** `src/components/berths/maintenance-form.tsx` — form with photo drag-and-drop upload **File:** `src/components/berths/berth-gallery.tsx` — photo grid (maintenance photos + manual uploads) **File:** `src/components/shared/file-upload.tsx` — reusable presigned URL upload component #### Day 5: Public API + Waiting List Stub + Integration **File:** `src/app/api/public/berths/route.ts` ```typescript export async function GET(req: NextRequest) { // Rate limit check (no auth required) const rateLimit = await checkRateLimit(getClientIp(req), rateLimiters.publicApi); if (!rateLimit.allowed) { return NextResponse.json( { error: 'Too many requests' }, { status: 429, headers: rateLimitHeaders(rateLimit), }, ); } const query = publicBerthQuerySchema.parse(Object.fromEntries(req.nextUrl.searchParams)); const result = await berthService.listPublic(query); return NextResponse.json(result, { headers: { 'Cache-Control': 'public, max-age=300', // 5 min 'Access-Control-Allow-Origin': process.env.PUBLIC_SITE_URL!, ...rateLimitHeaders(rateLimit), }, }); } ``` **File:** `src/app/api/public/berths/[id]/route.ts` — single berth (same restrictions) **File:** `src/app/api/public/interests/route.ts` — website interest registration: ```typescript export async function POST(req: NextRequest) { // Rate limit, validate, create client (or find by email), create interest // Run duplicate detection // Emit registration:new socket event // Enqueue notification job // Return 201 } ``` **Waiting list** UI stub — the data structures exist but full waiting list management is in L2. Place stub section in berth detail panel that says "Waiting list — available in next update." --- ### Stream C: Auth Admin (5 days) #### Day 1: User Management CRUD **File:** `src/lib/validators/admin-users.ts` **File:** `src/lib/services/admin-users.ts` ```typescript export const adminUserService = { async list(ctx: AuthContext, query: UserListQuery): Promise>, // Super admin: all users. Director: own port's users only. async getById(ctx: AuthContext, id: string): Promise, async create(ctx: AuthContext, data: CreateUserInput, meta: AuditMeta): Promise, // 1. Create user via Better Auth // 2. Generate password-set token (UUID + HMAC, 48hr expiry) // 3. Send email via Nodemailer (MJML template: set-password) // 4. Audit log async update(ctx: AuthContext, id: string, data: UpdateUserInput, meta: AuditMeta): Promise, async deactivate(ctx: AuthContext, id: string, meta: AuditMeta): Promise, // Deactivate + revoke all sessions async reactivate(ctx: AuthContext, id: string, meta: AuditMeta): Promise, async resetPassword(ctx: AuthContext, id: string, meta: AuditMeta): Promise, // Generate new password-set token + send email async assignToPort(ctx: AuthContext, targetUserId: string, portId: string, roleId: string, meta: AuditMeta): Promise, async removeFromPort(ctx: AuthContext, targetUserId: string, portId: string, meta: AuditMeta): Promise, async revokeSession(ctx: AuthContext, sessionId: string, meta: AuditMeta): Promise, async revokeAllSessions(ctx: AuthContext, userId: string, meta: AuditMeta): Promise, }; ``` **MJML Template:** **File:** `src/emails/set-password.mjml` - Port Nimara branding (navy header, logo) - "Welcome to Port Nimara CRM" heading - "Set your password" button linking to `/auth/set-password?token={token}` - 48-hour expiry notice - Security notice ("If you didn't request this, ignore this email") **File:** `src/lib/email/index.ts` — Nodemailer transporter factory: ```typescript export async function sendEmail(to: string, subject: string, html: string): Promise { const transporter = createTransport({ host: env.SMTP_HOST, port: env.SMTP_PORT, secure: env.SMTP_PORT === 465, auth: { user: env.SMTP_USER, pass: env.SMTP_PASS }, }); await transporter.sendMail({ from: `"Port Nimara CRM" <${env.SMTP_USER}>`, to, subject, html, }); } ``` **UI:** **File:** `src/app/(dashboard)/[portSlug]/admin/users/page.tsx` **File:** `src/app/(dashboard)/[portSlug]/admin/users/new/page.tsx` **File:** `src/app/(dashboard)/[portSlug]/admin/users/[id]/page.tsx` **File:** `src/components/admin/user-form.tsx` **File:** `src/components/admin/user-port-assignment.tsx` **File:** `src/components/admin/user-sessions-table.tsx` — active sessions with revoke #### Day 2: Role Builder **File:** `src/lib/services/admin-roles.ts` **File:** `src/lib/validators/admin-roles.ts` Role permissions use the exact structure from `10-AUTH-AND-PERMISSIONS.md` Section 2.2 — the `RolePermissions` type already defined in L0's `src/lib/db/schema/users.ts`. **UI:** **File:** `src/app/(dashboard)/[portSlug]/admin/roles/page.tsx` **File:** `src/app/(dashboard)/[portSlug]/admin/roles/[id]/page.tsx` **File:** `src/components/admin/role-form.tsx` **File:** `src/components/admin/permission-editor.tsx` Permission editor grid: - Rows: entity categories (clients, interests, berths, documents, expenses, invoices, files, email, reminders, calendar, reports, document_templates, admin) - Columns: permission actions per entity - `Switch` toggle per cell - "Select All" per row / "Select All" per column - "Clone from" dropdown: pre-populate from existing role - "What can this role do?" summary panel shadcn components: `Tabs`, `Switch`, `Select`, `Card`, `Badge`, `Table` #### Day 3: Port Management + Role Overrides **File:** `src/lib/services/admin-ports.ts` **File:** `src/lib/services/admin-role-overrides.ts` **File:** `src/lib/validators/admin-ports.ts` **UI:** **File:** `src/app/(dashboard)/[portSlug]/admin/ports/page.tsx` **File:** `src/app/(dashboard)/[portSlug]/admin/ports/[id]/page.tsx` **File:** `src/components/admin/port-form.tsx` — basic info, branding (logo upload), operational settings **File:** `src/components/admin/port-role-overrides.tsx` — per-role override editor for this port #### Day 4: Permission Enforcement + UI Gating **File:** `src/hooks/use-permissions.ts` ```typescript export function usePermissions() { // Reads from auth context (loaded during session init) const { permissions, isSuperAdmin } = useAuth(); return { can: (resource: string, action: string): boolean => { if (isSuperAdmin) return true; const resourcePerms = permissions?.[resource] as Record | undefined; return resourcePerms?.[action] === true; }, canAny: (...checks: [string, string][]): boolean => { if (isSuperAdmin) return true; return checks.some(([r, a]) => { const rp = permissions?.[r] as Record | undefined; return rp?.[a] === true; }); }, }; } ``` **File:** `src/components/shared/permission-gate.tsx` ```typescript export function PermissionGate({ resource, action, children, fallback = null, }: { resource: string; action: string; children: ReactNode; fallback?: ReactNode; }) { const { can } = usePermissions(); return can(resource, action) ? <>{children} : <>{fallback}; } ``` Apply across all L1 components: - Sidebar: hide nav items user can't access - List pages: hide "+ New" button without create permission - Detail pages: disable edit fields without edit permission - Delete/archive: hide buttons without delete permission - Admin section: hide entirely unless user has any admin permission #### Day 5: System Settings + Audit Log Viewer + Integration **File:** `src/lib/services/admin-settings.ts` **File:** `src/app/(dashboard)/[portSlug]/admin/settings/page.tsx` — tabbed settings page **File:** `src/app/(dashboard)/[portSlug]/admin/audit/page.tsx` — audit log viewer Audit log viewer: - Filterable DataTable: timestamp, user, action, entity type, entity ID, changed field, old→new - Filters: user, entity type, action type, date range - JSON diff viewer for before/after values - Export CSV button - Revert button (super admin only) — restores previous value **Integration tests for Day 5:** 1. Create user → assign to port with `sales_agent` role → log in as that user 2. Verify sidebar shows only permitted items 3. Verify API returns 403 for unauthorized actions 4. Verify permission-denied events appear in audit log 5. Navigate all pages → verify no broken links 6. Public berth API → verify CORS headers + rate limiting 7. Public interest registration → verify client + interest created --- ## 3. Code-Ready Details ### TanStack Query Key Structure | Entity | List Key | Detail Key | | ----------------- | ------------------------------------------------------ | ------------------------------- | | Clients | `['clients', portId, { page, limit, filters }]` | `['clients', portId, clientId]` | | Client Contacts | `['clients', portId, clientId, 'contacts']` | — | | Client Notes | `['clients', portId, clientId, 'notes', { page }]` | — | | Client Activity | `['clients', portId, clientId, 'activity', { page }]` | — | | Berths | `['berths', portId, { page, limit, filters }]` | `['berths', portId, berthId]` | | Berth Map | `['berths', portId, 'map']` | — | | Berth Maintenance | `['berths', portId, berthId, 'maintenance', { page }]` | — | | Users | `['admin', 'users', { page, limit }]` | `['admin', 'users', userId]` | | Roles | `['admin', 'roles']` | `['admin', 'roles', roleId]` | | Ports | `['admin', 'ports']` | `['admin', 'ports', portId]` | | Tags | `['tags', portId, entityType]` | — | | Saved Views | `['saved-views', portId, entityType]` | — | ### Socket.io → Query Invalidation ```typescript // src/hooks/use-realtime-invalidation.ts export function useRealtimeInvalidation() { const socket = useSocket(); const queryClient = useQueryClient(); const portId = usePortStore((s) => s.currentPortId); useEffect(() => { if (!socket) return; socket.on('client:created', () => queryClient.invalidateQueries({ queryKey: ['clients', portId] }), ); socket.on('client:updated', ({ clientId }) => { queryClient.invalidateQueries({ queryKey: ['clients', portId] }); queryClient.invalidateQueries({ queryKey: ['clients', portId, clientId] }); }); socket.on('berth:statusChanged', () => { queryClient.invalidateQueries({ queryKey: ['berths', portId] }); queryClient.invalidateQueries({ queryKey: ['berths', portId, 'map'] }); }); // ... all other events return () => { socket.removeAllListeners(); }; }, [socket, portId, queryClient]); } ``` ### shadcn Components Used in L1 All L0 components plus: - `DataTable` (TanStack Table) — all list pages - `Combobox` — tag picker, client search, berth search - `Tabs` — detail pages, settings page, permission editor - `Switch` — permission toggles, settings toggles - `ScrollArea` — detail panel, map panel - `Popover` — berth map tooltips - `Sheet` — mobile detail panel - `Slider` — berth size/price range filters --- ## 4. Acceptance Criteria ### Stream A: Clients 1. Client list page renders with paginated DataTable, all filters functional 2. Create client form includes all sections (basic info, vessel details, proxy, contacts, tags) 3. At least one contact required on create (Zod enforces) 4. Duplicate detection fires on create — exact email blocks, fuzzy name/phone warns with dialog 5. Client merge moves all child entities in a single transaction 6. Client detail page shows all 10 tabs with content 7. Notes have 15-minute edit window — edit button disappears after 15 min 8. Tags assignable/removable, filter-by-tag works on list 9. Saved views: save current filters, load saved view, delete saved view 10. Bulk actions: select rows → bulk tag, bulk archive, bulk export CSV 11. Activity timeline shows all entity changes 12. Audit trail shows raw log with JSON diff ### Stream B: Berths 13. Berth explorer renders three-panel layout (map + list + detail) 14. Map SVG colors berths by status, click selects, hover shows tooltip 15. Detail panel shows full spec sheet with dual unit display (m/ft) 16. Comparison view: select 2-3 berths, side-by-side table with diff highlighting 17. Tenure indicators: green/amber/red based on expiry date 18. Maintenance log: create entries with photos (presigned MinIO upload) 19. Gallery shows all photos for a berth 20. Public berth API: CORS for portnimara.com, rate limited, cached 5 min, no internal data leaked 21. Public interest registration: creates client + interest, runs duplicate detection ### Stream C: Auth Admin 22. User CRUD: create → password-set email sent → set password → login works 23. User deactivation revokes all sessions, prevents login 24. Port assignment: assign user to port with role, verify permissions apply 25. Role builder: granular permission toggles match `10-AUTH-AND-PERMISSIONS.md` structure 26. Port role overrides: override specific permissions for a role at a specific port 27. Permission enforcement: every API endpoint checks permission, returns 403 if denied 28. Permission-aware UI: unauthorized nav items hidden, buttons disabled, admin section gated 29. System settings page: SMTP test, Documenso test, backup config 30. Audit log viewer: filterable, exportable, revert (super admin) ### Cross-Stream 31. All mutations write to `audit_logs` with before/after values 32. All mutations emit Socket.io events to appropriate rooms 33. Socket.io events trigger TanStack Query invalidation (real-time updates) 34. Zero TypeScript errors, ESLint clean 35. All API endpoints validate input with Zod (400 on invalid) 36. Port scoping enforced on every query (no cross-port data leaks) --- ## 5. Self-Review Checklist - [ ] **Route paths:** All page files use `(dashboard)/[portSlug]/`, not `(crm)/` - [ ] **Permissions:** Use exact `RolePermissions` type from L0 schema, not invented permission keys - [ ] **API pattern:** All routes use `withAuth(withPermission(..., handler))` wrapper, no boilerplate - [ ] **Port scoping:** Every service function's first param is `portId`, every query filters by it - [ ] **Audit logging:** Every create/update/delete/archive/restore/merge calls `createAuditLog()` - [ ] **Socket events:** Every mutation emits the correct event from `11-REALTIME-AND-BACKGROUND-JOBS.md` - [ ] **Dual units:** Berth dimensions stored in meters, displayed as "Xm / Yft" - [ ] **Saved views:** `saved_views` table used, save/load/delete UI on all list pages - [ ] **Bulk operations:** Bulk tag/archive/export available on client list - [ ] **15-min edit window:** Note edit button disabled after 15 minutes, server enforces - [ ] **Duplicate detection:** `pg_trgm` extension enabled, GiST index on `clients.full_name` - [ ] **Merge transaction:** All child entities re-parented atomically - [ ] **Public API:** No auth, CORS restricted, rate limited, internal fields excluded - [ ] **MJML email:** Password-set email renders with PN branding - [ ] **Three-panel berth layout:** Map + list + detail, not flat list/detail - [ ] **No `any` types:** Zero `any` in all new code - [ ] **Error responses:** Consistent `{ error, code, details? }` shape, no stack traces --- ## Codex Addenda — Merged from Competing Plan Review ### 1. Shared CRUD Spine (CRITICAL — Adopt as Stream 0) Before building entity-specific CRUD in Streams A/B/C, build a **shared CRUD spine** as a dedicated sub-stream. Without a single table/query/form pattern, all entity streams will reinvent pagination, filters, audit diffs, optimistic updates, and detail-page layout independently. **Build once, reuse everywhere:** | Component | Path | Purpose | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------- | | `DataTable` | `src/components/ui/data-table.tsx` | Paginated, sortable, selectable table | | `FilterBar` | `src/components/ui/filter-bar.tsx` | Reusable filter controls | | `DetailLayout` | `src/components/ui/detail-layout.tsx` | Header + tabs + right rail + activity | | `ArchiveConfirmDialog` | `src/components/ui/archive-confirm-dialog.tsx` | Shared archive/restore pattern | | `buildListQuery` | `src/lib/http/list-query.ts` | Parse URL search params into typed query | | `diffEntity` | `src/lib/audit/diff.ts` | Compute field-level changes for audit | | `usePaginatedQuery` | `src/hooks/use-paginated-query.ts` | TanStack Query wrapper for list pages | | `useEntityOptions` | `src/hooks/use-entity-options.ts` | Reusable search-selects for client, berth, user, tag | **Key shared utilities:** ```ts export const listQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(25), q: z.string().trim().max(200).optional(), sort: z.string().max(50).optional(), order: z.enum(['asc', 'desc']).default('desc'), includeArchived: z.coerce.boolean().default(false), }); export function diffEntity>( before: T, after: T, ): AuditFieldChange[]; export async function archiveEntity(input: { table: 'clients' | 'interests' | 'expenses' | 'invoices'; id: string; ctx: RequestContext; }): Promise; ``` **SQL conventions from the spine:** - All list queries order by a deterministic secondary key: `updated_at DESC, id DESC` - All archive-capable entities use `archived_at IS NULL` in the default query path - Build a reusable `withPage()` helper returning `{ rows, total, pages }` ### 2. Permission Naming Discipline List endpoints require `*.view` permission, not `*.list`. The permission model is nested and already specified — do not invent `list` as a separate key. ### 3. Security: Record Enumeration Prevention Detail pages should convert unauthorized access to **404** (not 403) to avoid record enumeration. An attacker should not be able to distinguish "exists but forbidden" from "does not exist." ### 4. Duplicate Alerts via Notifications Table Duplicate alerts are backed by the `notifications` table with `type = 'duplicate_alert'` and `metadata` containing `{ candidateClientId, matchedClientId, score, reasons }`. Do not introduce an undocumented `duplicate_alerts` table. ### 5. Multiple Roles Per Port The schema supports multiple `user_port_roles` rows per user per port. Permission resolution should **union** roles. If Matt wants stricter one-role-per-port, that decision must be made before implementation. ### 6. Filter Edge Cases - Empty filter values are stripped before query building so they do not create accidental `= ''` conditions. - Archive and restore actions are idempotent. - Invalid filters return 400 with field-level messages.