Files
pn-new-crm/competing-plans/blessed/L1-CORE-CRUD.md
Matt 67d7e6e3d5
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00

49 KiB
Raw Blame History

L1 — Core CRUD (Competing Plan — Claude Code)

Duration: Days 1014 (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:

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<T>(req: NextRequest, schema: ZodSchema<T>): 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<T>(req: NextRequest, schema: ZodSchema<T>): Promise<T> {
  const raw = await req.json();
  return schema.parse(raw);
}

/**
 * Standard paginated list response shape.
 */
export interface PaginatedResponse<T> {
  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:

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<T>(
  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<number>`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:

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
  pagination: PaginatedResponse<TData>['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:

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:

export function useEntityList<T>(
  entityKey: string,
  portId: string,
  params: Record<string, unknown>,
  options?: { enabled?: boolean },
) {
  return useQuery<PaginatedResponse<T>>({
    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

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<typeof createClientSchema>;
export type UpdateClientInput = z.infer<typeof updateClientSchema>;
export type ClientListQuery = z.infer<typeof clientListQuerySchema>;

Service Layer:

File: src/lib/services/clients.ts

export const clientService = {
  async list(portId: string, query: ClientListQuery): Promise<PaginatedResponse<ClientSummary>>,
  async getById(portId: string, id: string): Promise<ClientDetail>,
  async create(portId: string, userId: string, data: CreateClientInput, meta: AuditMeta): Promise<ClientDetail>,
  async update(portId: string, userId: string, id: string, data: UpdateClientInput, meta: AuditMeta): Promise<ClientDetail>,
  async archive(portId: string, userId: string, id: string, meta: AuditMeta): Promise<void>,
  async restore(portId: string, userId: string, id: string, meta: AuditMeta): Promise<void>,
  async exportPdf(portId: string, id: string): Promise<Buffer>,  // 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

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

export const contactService = {
  async listForClient(portId: string, clientId: string): Promise<ClientContact[]>,
  async create(portId: string, userId: string, clientId: string, data: CreateContactInput, meta: AuditMeta): Promise<ClientContact>,
  async update(portId: string, userId: string, contactId: string, data: UpdateContactInput, meta: AuditMeta): Promise<ClientContact>,
  async delete(portId: string, userId: string, contactId: string, meta: AuditMeta): Promise<void>,
  async setPrimary(portId: string, userId: string, clientId: string, contactId: string, meta: AuditMeta): Promise<void>,
};

Relationships Service:

File: src/lib/services/client-relationships.ts

export const relationshipService = {
  async listForClient(portId: string, clientId: string): Promise<ClientRelationship[]>,
  async create(portId: string, userId: string, data: CreateRelationshipInput, meta: AuditMeta): Promise<ClientRelationship>,
  // Creates bidirectional: A→B and B→A with inverse relationship types
  async delete(portId: string, userId: string, relationshipId: string, meta: AuditMeta): Promise<void>,
  // Deletes both directions
};

Relationship types: referral, broker, family, same_vessel, business_partner, custom Inverse mappings: referralreferred_by, brokerbroker_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

export const noteService = {
  async listForEntity(portId: string, entityType: 'client' | 'interest', entityId: string, query: NoteListQuery): Promise<PaginatedResponse<Note>>,
  async create(portId: string, userId: string, entityType: string, entityId: string, data: CreateNoteInput, meta: AuditMeta): Promise<Note>,
  async update(portId: string, userId: string, noteId: string, data: UpdateNoteInput, meta: AuditMeta): Promise<Note>,
  // Enforces 15-minute edit window: throws if createdAt + 15min < now
  async delete(portId: string, userId: string, noteId: string, meta: AuditMeta): Promise<void>,
};

@mention format: [[@userId:displayName]] — stored as tokens, rendered as styled chips in UI.

Tags Service:

File: src/lib/services/tags.ts — shared across entities

export const tagService = {
  async list(portId: string, entityType?: string): Promise<Tag[]>,
  async create(portId: string, userId: string, data: { name: string; color: string; entityType: string }, meta: AuditMeta): Promise<Tag>,
  async update(portId: string, userId: string, tagId: string, data: { name?: string; color?: string }, meta: AuditMeta): Promise<Tag>,
  async delete(portId: string, userId: string, tagId: string, meta: AuditMeta): Promise<void>,
  async assignToEntity(portId: string, userId: string, entityType: string, entityId: string, tagId: string, meta: AuditMeta): Promise<void>,
  async removeFromEntity(portId: string, userId: string, entityType: string, entityId: string, tagId: string, meta: AuditMeta): Promise<void>,
};

Saved Views Service:

File: src/lib/services/saved-views.ts

export const savedViewService = {
  async list(portId: string, userId: string, entityType: string): Promise<SavedView[]>,
  async create(portId: string, userId: string, data: CreateSavedViewInput): Promise<SavedView>,
  async update(portId: string, userId: string, viewId: string, data: UpdateSavedViewInput): Promise<SavedView>,
  async delete(portId: string, userId: string, viewId: string): Promise<void>,
};

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

export const duplicateService = {
  async checkOnCreate(portId: string, data: CreateClientInput): Promise<DuplicateCandidate[]>,
  // 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<DuplicateCandidate[]>,
};

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:

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

export const mergeService = {
  async merge(portId: string, userId: string, masterId: string, duplicateId: string, meta: AuditMeta): Promise<ClientDetail>,
  // 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

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<Buffer>,
};

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

export const activityService = {
  async getForEntity(portId: string, entityType: string, entityId: string, query: ActivityQuery): Promise<PaginatedResponse<ActivityEntry>>,
  async getRecentForPort(portId: string, query: ActivityQuery): Promise<PaginatedResponse<ActivityEntry>>,
  // 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

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:

// Color mapping per berth status
const STATUS_COLORS: Record<BerthStatus, string> = {
  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:

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()

File: src/lib/services/berth-maintenance.ts

export const maintenanceService = {
  async list(portId: string, berthId: string, query: MaintenanceQuery): Promise<PaginatedResponse<MaintenanceEntry>>,
  async create(portId: string, userId: string, berthId: string, data: CreateMaintenanceInput, meta: AuditMeta): Promise<MaintenanceEntry>,
  async update(portId: string, userId: string, entryId: string, data: UpdateMaintenanceInput, meta: AuditMeta): Promise<MaintenanceEntry>,
  async delete(portId: string, userId: string, entryId: string, meta: AuditMeta): Promise<void>,
  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

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:

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

export const adminUserService = {
  async list(ctx: AuthContext, query: UserListQuery): Promise<PaginatedResponse<UserSummary>>,
  // Super admin: all users. Director: own port's users only.
  async getById(ctx: AuthContext, id: string): Promise<UserDetail>,
  async create(ctx: AuthContext, data: CreateUserInput, meta: AuditMeta): Promise<UserDetail>,
  // 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<UserDetail>,
  async deactivate(ctx: AuthContext, id: string, meta: AuditMeta): Promise<void>,
  // Deactivate + revoke all sessions
  async reactivate(ctx: AuthContext, id: string, meta: AuditMeta): Promise<void>,
  async resetPassword(ctx: AuthContext, id: string, meta: AuditMeta): Promise<void>,
  // Generate new password-set token + send email
  async assignToPort(ctx: AuthContext, targetUserId: string, portId: string, roleId: string, meta: AuditMeta): Promise<void>,
  async removeFromPort(ctx: AuthContext, targetUserId: string, portId: string, meta: AuditMeta): Promise<void>,
  async revokeSession(ctx: AuthContext, sessionId: string, meta: AuditMeta): Promise<void>,
  async revokeAllSessions(ctx: AuthContext, userId: string, meta: AuditMeta): Promise<void>,
};

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:

export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
  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

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<string, boolean> | 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<string, boolean> | undefined;
        return rp?.[a] === true;
      });
    },
  };
}

File: src/components/shared/permission-gate.tsx

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

// 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

  1. Berth explorer renders three-panel layout (map + list + detail)
  2. Map SVG colors berths by status, click selects, hover shows tooltip
  3. Detail panel shows full spec sheet with dual unit display (m/ft)
  4. Comparison view: select 2-3 berths, side-by-side table with diff highlighting
  5. Tenure indicators: green/amber/red based on expiry date
  6. Maintenance log: create entries with photos (presigned MinIO upload)
  7. Gallery shows all photos for a berth
  8. Public berth API: CORS for portnimara.com, rate limited, cached 5 min, no internal data leaked
  9. Public interest registration: creates client + interest, runs duplicate detection

Stream C: Auth Admin

  1. User CRUD: create → password-set email sent → set password → login works
  2. User deactivation revokes all sessions, prevents login
  3. Port assignment: assign user to port with role, verify permissions apply
  4. Role builder: granular permission toggles match 10-AUTH-AND-PERMISSIONS.md structure
  5. Port role overrides: override specific permissions for a role at a specific port
  6. Permission enforcement: every API endpoint checks permission, returns 403 if denied
  7. Permission-aware UI: unauthorized nav items hidden, buttons disabled, admin section gated
  8. System settings page: SMTP test, Documenso test, backup config
  9. Audit log viewer: filterable, exportable, revert (super admin)

Cross-Stream

  1. All mutations write to audit_logs with before/after values
  2. All mutations emit Socket.io events to appropriate rooms
  3. Socket.io events trigger TanStack Query invalidation (real-time updates)
  4. Zero TypeScript errors, ESLint clean
  5. All API endpoints validate input with Zod (400 on invalid)
  6. 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:

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<T extends Record<string, unknown>>(
  before: T,
  after: T,
): AuditFieldChange[];

export async function archiveEntity(input: {
  table: 'clients' | 'interests' | 'expenses' | 'invoices';
  id: string;
  ctx: RequestContext;
}): Promise<void>;

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.