Files
pn-new-crm/competing-plans/blessed/L1-CORE-CRUD.md

1154 lines
49 KiB
Markdown
Raw Permalink Normal View 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:
```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<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:
```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<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:
```typescript
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:
```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<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`
```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<typeof createClientSchema>;
export type UpdateClientInput = z.infer<typeof updateClientSchema>;
export type ClientListQuery = z.infer<typeof clientListQuerySchema>;
```
**Service Layer:**
**File:** `src/lib/services/clients.ts`
```typescript
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`
```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<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`
```typescript
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: `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<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
```typescript
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`
```typescript
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`
```typescript
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`:
```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<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`
```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<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`
```typescript
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`
```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<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:
```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<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`
```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<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:
```typescript
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`
```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<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`
```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<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.