Files
pn-new-crm/competing-plans/blessed/L0-FOUNDATION.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

91 KiB
Raw Blame History

L0 — Foundation (Competing Plan — Claude Code)

Duration: Days 69 (4 days) Parallelism: None — sequential foundation; everything depends on this References: 07-DATABASE-SCHEMA.md, 10-AUTH-AND-PERMISSIONS.md, 11-REALTIME-AND-BACKGROUND-JOBS.md, 14-TECHNICAL-DECISIONS.md, 15-DESIGN-TOKENS.md, SECURITY-GUIDELINES.md


1. Baseline Critique

What the baseline gets right

  • Step ordering is sound. Scaffold → Docker → DB → Auth → Infra → Layout → Security is the correct dependency chain.
  • Drizzle schema grouping (1 file per domain) is practical and matches the project scale.
  • Docker multi-stage build with standalone output is correct for production.
  • Nginx security headers and rate limiting zones are thorough and match SECURITY-GUIDELINES.md.
  • Acceptance criteria are concrete and testable.

What's missing or wrong

  1. Route structure is wrong. The baseline uses (crm)/ as the route group. The locked file organization in PROMPT-CLAUDE-CODE.md specifies (dashboard)/[portSlug]/ with a dynamic port slug segment. This is a fundamental routing decision — every page URL, every link, every breadcrumb depends on it. Getting this wrong means rework across every layer.

  2. Session storage contradicts 14-TECHNICAL-DECISIONS.md. The baseline stores sessions in PostgreSQL. The locked tech decisions say better-auth/plugins/redis for session persistence. Redis sessions are faster and reduce DB load for the most frequent operation in the system (session validation on every request).

  3. BullMQ queue names don't match the spec. The baseline invents names like notification-processing, email-sync, email-send. The spec in 11-REALTIME-AND-BACKGROUND-JOBS.md defines exactly 10 queues: email, documents, notifications, import, export, reports, webhooks, maintenance, ai, bulk. Use the spec names.

  4. Role count mismatch. The baseline seeds 4 roles (super_admin, director, sales, readonly). The spec in 10-AUTH-AND-PERMISSIONS.md Section 2.4 defines 5 system roles: super_admin, director, sales_manager, sales_agent, viewer. All 5 must be seeded.

  5. Missing environment variables. The .env.example omits CSRF_SECRET, EMAIL_CREDENTIAL_KEY, DOCUMENSO_WEBHOOK_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, PUBLIC_SITE_URL — all required by SECURITY-GUIDELINES.md.

  6. Socket.io + Next.js integration is hand-waved. "Create Socket.io server alongside Next.js custom server" is not a plan. Next.js standalone mode runs node server.js with no extension point for WebSocket servers. This needs a custom server.ts entry point that boots both Next.js and Socket.io, or a separate worker process. The approach must be specified.

  7. No Drizzle relations defined. Schemas without relations() declarations mean no relational query builder (db.query.*.findFirst({ with: ... })). The auth middleware pseudocode in the baseline itself uses with: { role: true } — this requires relation definitions.

  8. No application-level rate limiting. The baseline only has nginx rate limiting. SECURITY-GUIDELINES.md Section 6.1 requires Redis sliding window rate limiting at the application layer with X-RateLimit-* response headers.

  9. No pgcrypto extension. Security guidelines require AES-256-GCM encryption for email credentials and pgcrypto for Google Calendar tokens. The database init must enable this extension.

  10. No env validation. No Zod schema validating that all required environment variables are present and well-formed at startup. The app should fail fast with a clear error, not crash mid-request when process.env.DATABASE_URL is undefined.

  11. No custom server entry point. BullMQ workers need a long-running process. Next.js standalone server.js doesn't run background workers. Need either a custom server wrapper or a separate worker process in Docker Compose.

  12. Missing lint-staged. Pre-commit hook runs ESLint on the entire project. With lint-staged + husky, only staged files are linted — dramatically faster as the project grows.

  13. No health check endpoints. Docker Compose health checks need an HTTP endpoint. The baseline's postgres and redis have health checks, but crm-app has none.

  14. next.config.ts incomplete. Needs serverExternalPackages for pino, bullmq, ioredis, minio, postgres — these packages use native Node.js APIs that Next.js's bundler will break if it tries to bundle them.

What I'd change structurally

  • Split the custom server concern early. Define a server.ts that boots Next.js + Socket.io + BullMQ workers together in development, and use separate Docker Compose services for crm-app (Next.js) and crm-worker (BullMQ) in production.
  • Define Zod env schema as the very first file. Every other module imports validated config from it.
  • Port slug in URLs from Day 1. /(dashboard)/[portSlug]/clients not /(crm)/clients. This avoids a painful routing migration later.
  • Add a lib/api/helpers.ts pattern for API route handlers — a composable middleware chain (withAuth → withPort → withPermission → handler) rather than ad-hoc checks in each route.

2. Implementation Plan

Day 1 — Scaffold + Docker + Database Schema

Morning: Project Init (2 hours)

Step 1: Create Next.js project

pnpm create next-app@latest port-nimara-crm \
  --typescript --tailwind --eslint --app \
  --src-dir --import-alias "@/*" \
  --use-pnpm

Step 2: Environment validation

File: src/lib/env.ts

import { z } from 'zod';

const envSchema = z.object({
  // Database
  DATABASE_URL: z.string().url().startsWith('postgresql://'),

  // Redis
  REDIS_URL: z.string().url().startsWith('redis://'),

  // Auth
  BETTER_AUTH_SECRET: z.string().min(32),
  BETTER_AUTH_URL: z.string().url(),
  CSRF_SECRET: z.string().min(32),

  // MinIO
  MINIO_ENDPOINT: z.string().min(1),
  MINIO_PORT: z.coerce.number().int().positive(),
  MINIO_ACCESS_KEY: z.string().min(1),
  MINIO_SECRET_KEY: z.string().min(1),
  MINIO_BUCKET: z.string().min(1),
  MINIO_USE_SSL: z.enum(['true', 'false']).transform((v) => v === 'true'),

  // Documenso
  DOCUMENSO_API_URL: z.string().url(),
  DOCUMENSO_API_KEY: z.string().min(1),
  DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),

  // Email
  SMTP_HOST: z.string().min(1),
  SMTP_PORT: z.coerce.number().int().positive(),

  // Encryption
  EMAIL_CREDENTIAL_KEY: z
    .string()
    .length(64)
    .regex(/^[0-9a-f]+$/i, 'Must be 64-char hex'),

  // Google OAuth
  GOOGLE_CLIENT_ID: z.string().optional(),
  GOOGLE_CLIENT_SECRET: z.string().optional(),

  // OpenAI
  OPENAI_API_KEY: z.string().optional(),

  // App
  APP_URL: z.string().url(),
  PUBLIC_SITE_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
});

export type Env = z.infer<typeof envSchema>;

function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);
  if (!result.success) {
    console.error('❌ Invalid environment variables:');
    for (const issue of result.error.issues) {
      console.error(`  ${issue.path.join('.')}: ${issue.message}`);
    }
    process.exit(1);
  }
  return result.data;
}

export const env = validateEnv();

Step 3: TypeScript strict config

File: tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true,
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "ES2022"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./src/*"] },
    "skipLibCheck": true
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

Step 4: ESLint + Prettier + lint-staged

pnpm add -D prettier eslint-config-prettier lint-staged

File: .eslintrc.json

{
  "extends": ["next/core-web-vitals", "next/typescript", "prettier"],
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
    "import/order": [
      "warn",
      {
        "groups": ["builtin", "external", "internal", "parent", "sibling"],
        "newlines-between": "always",
        "alphabetize": { "order": "asc" }
      }
    ]
  }
}

File: .prettierrc

{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "tabWidth": 2,
  "printWidth": 100
}

File: .lintstagedrc.json

{
  "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
  "*.{json,md,css}": ["prettier --write"]
}

Step 5: Git setup

File: .gitignore

node_modules/
.next/
.env
.env.local
.env.production
*.pem
*.key
drizzle/*.sql
coverage/
.turbo/

File: .env.example — all variables from src/lib/env.ts schema with placeholder values (no real secrets).

pnpm add -D husky
pnpm exec husky init

File: .husky/pre-commit

#!/bin/sh
pnpm exec lint-staged
# Verify no .env files staged
if git diff --cached --name-only | grep -qE '\.env($|\.)'; then
  echo "❌ .env files must not be committed"
  exit 1
fi
# Scan for potential secrets
if git diff --cached -U0 | grep -qiE '(password|secret|api_key|access_key)\s*[:=]\s*["\x27][A-Za-z0-9+/=]{16,}'; then
  echo "⚠️  Possible hardcoded secret detected. Review staged changes."
  exit 1
fi

Step 6: Directory structure

Create all directories per the locked file organization in PROMPT-CLAUDE-CODE.md. Key difference from baseline: (dashboard)/[portSlug]/ not (crm)/.

src/
├── app/
│   ├── (auth)/
│   │   ├── login/
│   │   ├── set-password/
│   │   └── reset-password/
│   ├── (dashboard)/
│   │   ├── [portSlug]/
│   │   │   ├── clients/
│   │   │   ├── interests/
│   │   │   ├── berths/
│   │   │   ├── expenses/
│   │   │   ├── invoices/
│   │   │   ├── email/
│   │   │   ├── reminders/
│   │   │   ├── documents/
│   │   │   ├── reports/
│   │   │   ├── settings/
│   │   │   └── admin/
│   │   │       ├── users/
│   │   │       ├── roles/
│   │   │       ├── ports/
│   │   │       ├── settings/
│   │   │       ├── audit/
│   │   │       ├── webhooks/
│   │   │       ├── reports/
│   │   │       ├── templates/
│   │   │       ├── forms/
│   │   │       ├── tags/
│   │   │       ├── import/
│   │   │       ├── monitoring/
│   │   │       ├── backup/
│   │   │       ├── custom-fields/
│   │   │       └── onboarding/
│   │   └── layout.tsx
│   ├── api/
│   │   ├── auth/[...all]/
│   │   ├── health/
│   │   ├── v1/
│   │   │   ├── clients/
│   │   │   ├── interests/
│   │   │   ├── berths/
│   │   │   ├── expenses/
│   │   │   ├── invoices/
│   │   │   ├── files/
│   │   │   ├── email/
│   │   │   ├── reminders/
│   │   │   ├── documents/
│   │   │   ├── reports/
│   │   │   ├── notifications/
│   │   │   ├── calendar/
│   │   │   ├── admin/
│   │   │   └── search/
│   │   ├── public/
│   │   └── webhooks/
│   ├── layout.tsx
│   └── not-found.tsx
├── components/
│   ├── ui/                    # shadcn/ui
│   ├── layout/                # Sidebar, Topbar, PortSwitcher, Breadcrumbs
│   ├── shared/                # Reusable composed components
│   └── [domain]/              # Per-domain (clients/, interests/, berths/, etc.)
├── lib/
│   ├── db/
│   │   ├── index.ts           # Drizzle client
│   │   ├── schema/            # 1 file per domain + relations
│   │   │   ├── index.ts
│   │   │   ├── ports.ts
│   │   │   ├── users.ts
│   │   │   ├── clients.ts
│   │   │   ├── interests.ts
│   │   │   ├── berths.ts
│   │   │   ├── documents.ts
│   │   │   ├── financial.ts
│   │   │   ├── email.ts
│   │   │   ├── operations.ts
│   │   │   ├── system.ts
│   │   │   └── relations.ts   # ← NEW: all relations() in one file
│   │   ├── migrations/
│   │   └── seed.ts
│   ├── auth/
│   │   ├── index.ts           # Better Auth server
│   │   ├── client.ts          # Better Auth React hooks
│   │   └── permissions.ts     # Permission check helpers
│   ├── api/
│   │   └── helpers.ts         # ← NEW: withAuth/withPort/withPermission composable chain
│   ├── services/              # Business logic (1 file per domain, added in L1+)
│   ├── validators/            # Zod schemas (1 file per domain, added in L1+)
│   ├── queue/
│   │   ├── index.ts           # Queue definitions (10 queues matching spec)
│   │   ├── scheduler.ts       # Recurring job registration
│   │   └── workers/           # Worker processors (stubs)
│   ├── socket/
│   │   ├── server.ts          # Socket.io setup
│   │   ├── events.ts          # Event type definitions
│   │   └── rooms.ts           # Room join/leave logic
│   ├── minio/
│   │   └── index.ts
│   ├── email/
│   │   └── index.ts           # Nodemailer transporter factory
│   ├── redis.ts
│   ├── rate-limit.ts          # ← NEW: Redis sliding window rate limiter
│   ├── logger.ts
│   ├── errors.ts
│   ├── constants.ts
│   ├── audit.ts               # ← NEW: dedicated audit log module
│   ├── env.ts
│   └── utils.ts
├── hooks/
│   ├── use-auth.ts
│   ├── use-port.ts
│   ├── use-socket.ts
│   └── use-permissions.ts
├── providers/
│   ├── query-provider.tsx
│   ├── socket-provider.tsx
│   └── port-provider.tsx
├── stores/
│   └── ui-store.ts
├── types/
│   ├── api.ts
│   ├── auth.ts
│   └── domain.ts
├── jobs/                       # ← matches locked structure
│   └── (empty — populated in L2+)
├── emails/                     # MJML templates
│   └── (empty — populated in L2+)
└── server.ts                   # ← NEW: custom server entry point

Morning: Docker Compose (1 hour)

Step 7: Docker Compose

File: docker-compose.yml

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: port_nimara_crm
      POSTGRES_USER: ${DB_USER:-crm}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U crm -d port_nimara_crm']
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redisdata:/data
    healthcheck:
      test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD}', 'ping']
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  crm-app:
    build:
      context: .
      dockerfile: Dockerfile
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test:
        ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/api/health']
      interval: 15s
      timeout: 5s
      retries: 3
    networks:
      - internal

  crm-worker:
    build:
      context: .
      dockerfile: Dockerfile.worker
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - internal

  nginx:
    image: nginx:alpine
    ports:
      - '443:443'
      - '80:80'
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      crm-app:
        condition: service_healthy
    networks:
      - internal

volumes:
  pgdata:
  redisdata:

networks:
  internal:
    driver: bridge

File: docker-compose.dev.yml (override)

services:
  postgres:
    ports:
      - '5432:5432'
  redis:
    ports:
      - '6379:6379'
  crm-app:
    build:
      dockerfile: Dockerfile.dev
    ports:
      - '3000:3000'
    volumes:
      - .:/app
      - /app/node_modules
    command: pnpm dev
  crm-worker:
    profiles: ['worker'] # Optional in dev — server.ts runs workers inline
  nginx:
    profiles: ['nginx'] # Skip nginx in dev

File: docker/postgres/init.sql

CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

File: Dockerfile

FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false

FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build

FROM node:20-alpine AS runner
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

File: Dockerfile.worker

FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod

FROM node:20-alpine AS runner
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY dist/worker.js ./worker.js
USER worker
CMD ["node", "worker.js"]

File: next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'standalone',
  serverExternalPackages: [
    'pino',
    'pino-pretty',
    'bullmq',
    'ioredis',
    'minio',
    'postgres',
    'better-auth',
    'argon2',
    'nodemailer',
  ],
  images: {
    remotePatterns: [{ protocol: 'https', hostname: '*.portnimara.com' }],
  },
  experimental: {
    typedRoutes: true,
  },
};

export default nextConfig;

Afternoon: Database Layer (4 hours)

Step 8: Install Drizzle + driver

pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit

Step 9: Drizzle config

File: drizzle.config.ts

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/lib/db/schema',
  out: './src/lib/db/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

Step 10: Drizzle client

File: src/lib/db/index.ts

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

import * as schema from './schema';

const connectionString = process.env.DATABASE_URL!;

// Connection pool for queries
const queryClient = postgres(connectionString, {
  max: 20,
  idle_timeout: 20,
  connect_timeout: 10,
});

export const db = drizzle(queryClient, { schema, logger: process.env.NODE_ENV === 'development' });

export type Database = typeof db;

Step 11: Define ALL 49 table schemas

Each schema file follows these conventions from 07-DATABASE-SCHEMA.md:

  • UUID primary keys: uuid('id').primaryKey().defaultRandom()
  • Timestamps: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
  • Port scoping: uuid('port_id').notNull().references(() => ports.id, { onDelete: 'cascade' })
  • Soft deletes: timestamp('archived_at', { withTimezone: true })

Schema files (matching 07-DATABASE-SCHEMA.md exactly):

File: src/lib/db/schema/ports.ts

import { pgTable, uuid, varchar, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core';

export const ports = pgTable('ports', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: varchar('name', { length: 100 }).notNull(),
  slug: varchar('slug', { length: 50 }).notNull().unique(),
  defaultCurrency: varchar('default_currency', { length: 3 }).notNull().default('USD'),
  timezone: varchar('timezone', { length: 50 }).notNull().default('America/Anguilla'),
  settings: jsonb('settings').$type<PortSettings>().default({}),
  branding: jsonb('branding').$type<PortBranding>().default({}),
  isActive: boolean('is_active').notNull().default(true),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

export type PortSettings = {
  followUpReminderDays?: number[];
  tenureExpiryWarningMonths?: number;
  eoiReminderIntervalDays?: number;
  berthStatusRules?: Array<{
    trigger: string;
    mode: 'auto' | 'suggest' | 'off';
    targetStatus: string;
  }>;
};

export type PortBranding = {
  logoUrl?: string;
  primaryColor?: string;
  secondaryColor?: string;
};

File: src/lib/db/schema/users.ts

import { pgTable, uuid, varchar, boolean, timestamp, jsonb, index } from 'drizzle-orm/pg-core';

import { ports } from './ports';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  name: varchar('name', { length: 255 }).notNull(),
  emailVerified: boolean('email_verified').notNull().default(false),
  image: varchar('image', { length: 500 }),
  isSuperAdmin: boolean('is_super_admin').notNull().default(false),
  isActive: boolean('is_active').notNull().default(true),
  timezone: varchar('timezone', { length: 50 }).default('America/Anguilla'),
  preferences: jsonb('preferences').$type<UserPreferences>().default({}),
  lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

export type UserPreferences = {
  sidebarCollapsed?: boolean;
  darkMode?: boolean;
  defaultPortId?: string;
  notificationChannels?: Record<string, { inApp: boolean; email: boolean }>;
};

export const roles = pgTable('roles', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: varchar('name', { length: 100 }).notNull().unique(),
  description: varchar('description', { length: 500 }),
  permissions: jsonb('permissions').$type<RolePermissions>().notNull(),
  isSystem: boolean('is_system').notNull().default(false),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

export const userPortRoles = pgTable(
  'user_port_roles',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    userId: uuid('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    portId: uuid('port_id')
      .notNull()
      .references(() => ports.id, { onDelete: 'cascade' }),
    roleId: uuid('role_id')
      .notNull()
      .references(() => roles.id, { onDelete: 'restrict' }),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [index('idx_upr_user_port').on(table.userId, table.portId)],
);

export const portRoleOverrides = pgTable('port_role_overrides', {
  id: uuid('id').primaryKey().defaultRandom(),
  portId: uuid('port_id')
    .notNull()
    .references(() => ports.id, { onDelete: 'cascade' }),
  roleId: uuid('role_id')
    .notNull()
    .references(() => roles.id, { onDelete: 'cascade' }),
  permissionOverrides: jsonb('permission_overrides').$type<Partial<RolePermissions>>().notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

export const sessions = pgTable(
  'sessions',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    userId: uuid('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    token: varchar('token', { length: 500 }).notNull().unique(),
    expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
    ipAddress: varchar('ip_address', { length: 45 }),
    userAgent: varchar('user_agent', { length: 500 }),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    index('idx_sessions_token').on(table.token),
    index('idx_sessions_user').on(table.userId),
  ],
);

export type RolePermissions = {
  clients: {
    view: boolean;
    create: boolean;
    edit: boolean;
    delete: boolean;
    merge: boolean;
    export: boolean;
  };
  interests: {
    view: boolean;
    create: boolean;
    edit: boolean;
    delete: boolean;
    change_stage: boolean;
    generate_eoi: boolean;
    export: boolean;
  };
  berths: { view: boolean; edit: boolean; import: boolean; manage_waiting_list: boolean };
  documents: {
    view: boolean;
    create: boolean;
    send_for_signing: boolean;
    upload_signed: boolean;
    delete: boolean;
  };
  expenses: {
    view: boolean;
    create: boolean;
    edit: boolean;
    delete: boolean;
    export: boolean;
    scan_receipt: boolean;
  };
  invoices: {
    view: boolean;
    create: boolean;
    edit: boolean;
    delete: boolean;
    send: boolean;
    record_payment: boolean;
    export: boolean;
  };
  files: { view: boolean; upload: boolean; delete: boolean; manage_folders: boolean };
  email: { view: boolean; send: boolean; configure_account: boolean };
  reminders: {
    view_own: boolean;
    view_all: boolean;
    create: boolean;
    edit_own: boolean;
    edit_all: boolean;
    assign_others: boolean;
  };
  calendar: { connect: boolean; view_events: boolean };
  reports: { view_dashboard: boolean; view_analytics: boolean; export: boolean };
  document_templates: { view: boolean; generate: boolean; manage: boolean };
  admin: {
    manage_users: boolean;
    view_audit_log: boolean;
    manage_settings: boolean;
    manage_webhooks: boolean;
    manage_reports: boolean;
    manage_custom_fields: boolean;
    manage_forms: boolean;
    manage_tags: boolean;
    system_backup: boolean;
  };
};

I won't reproduce the full schema for every table here (the remaining 40+ tables follow the same conventions), but the key schema files and their table lists:

File: src/lib/db/schema/clients.tsclients, client_contacts, client_relationships, client_notes, client_tags, client_merge_log

File: src/lib/db/schema/interests.tsinterests, interest_notes, interest_tags

File: src/lib/db/schema/berths.tsberths, berth_map_data, berth_recommendations, berth_waiting_list, berth_maintenance_log, berth_tags

File: src/lib/db/schema/documents.tsdocuments, document_signers, document_events, document_templates, form_templates, form_submissions

File: src/lib/db/schema/financial.tsexpenses, invoices, invoice_line_items, invoice_expenses

File: src/lib/db/schema/email.tsemail_accounts, email_threads, email_messages

File: src/lib/db/schema/operations.tsreminders, google_calendar_tokens, google_calendar_cache, notifications, scheduled_reports, report_recipients

File: src/lib/db/schema/system.tsaudit_logs, tags, files, webhooks, webhook_deliveries, system_settings, saved_views, scratchpad_notes, user_notification_preferences, currency_rates, custom_field_definitions, custom_field_values

Critical: Every port-scoped table includes portId: uuid('port_id').notNull().references(() => ports.id, { onDelete: 'cascade' }) and an index on port_id. Tables with soft deletes include archivedAt column. All foreign keys specify onDelete behavior per 07-DATABASE-SCHEMA.md.

File: src/lib/db/schema/relations.ts

import { relations } from 'drizzle-orm';
import { ports } from './ports';
import { users, roles, userPortRoles, portRoleOverrides, sessions } from './users';
import { clients, clientContacts, clientRelationships, clientNotes, clientTags } from './clients';
// ... all other imports

export const portsRelations = relations(ports, ({ many }) => ({
  userPortRoles: many(userPortRoles),
  portRoleOverrides: many(portRoleOverrides),
  clients: many(clients),
  interests: many(interests),
  berths: many(berths),
}));

export const usersRelations = relations(users, ({ many }) => ({
  sessions: many(sessions),
  portRoles: many(userPortRoles),
}));

export const userPortRolesRelations = relations(userPortRoles, ({ one }) => ({
  user: one(users, { fields: [userPortRoles.userId], references: [users.id] }),
  port: one(ports, { fields: [userPortRoles.portId], references: [ports.id] }),
  role: one(roles, { fields: [userPortRoles.roleId], references: [roles.id] }),
}));

// ... relations for all other tables following the same pattern
// Every FK gets a corresponding relation() declaration

File: src/lib/db/schema/index.ts

export * from './ports';
export * from './users';
export * from './clients';
export * from './interests';
export * from './berths';
export * from './documents';
export * from './financial';
export * from './email';
export * from './operations';
export * from './system';
export * from './relations';

Step 12: Run initial migration

pnpm drizzle-kit generate
pnpm drizzle-kit push

Verify: all 49 tables created with correct columns, constraints, indexes, and the pgcrypto + uuid-ossp extensions enabled.

Step 13: Seed data

File: src/lib/db/seed.ts

Seeds:

  1. Default port: { name: 'Port Nimara', slug: 'port-nimara', defaultCurrency: 'USD', timezone: 'America/Anguilla' }
  2. Five system roles matching 10-AUTH-AND-PERMISSIONS.md Section 2.4:
    • super_admin — all permissions true
    • director — all operational permissions true, admin.manage_users: true, admin.view_audit_log: true, system admin false
    • sales_manager — full sales access, reminders.view_all: true, reminders.assign_others: true
    • sales_agent — standard sales (view/create/edit clients/interests, own reminders only)
    • viewer — all view permissions true, everything else false
  3. Super admin user: { email: 'matt@portnimara.com', name: 'Matt', isSuperAdmin: true } — password set via email flow
  4. Assign Matt to Port Nimara with super_admin role

Run via: pnpm tsx src/lib/db/seed.ts


Day 2 — Auth + Infrastructure

Morning: Authentication (3 hours)

Step 14: Better Auth setup

pnpm add better-auth @better-auth/redis

File: src/lib/auth/index.ts

import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { redis as redisPlugin } from '@better-auth/redis';

import { db } from '@/lib/db';
import { redis } from '@/lib/redis';
import { logger } from '@/lib/logger';

export const auth = betterAuth({
  database: drizzleAdapter(db),
  emailAndPassword: {
    enabled: true,
    minPasswordLength: 12,
    requireEmailVerification: false, // Admin-created accounts, no self-signup
  },
  session: {
    cookieCache: { enabled: true, maxAge: 5 * 60 },
    expiresIn: 60 * 60 * 24, // 24 hours absolute
    updateAge: 60 * 60 * 6, // Refresh in last 25%
  },
  plugins: [
    redisPlugin({ redis }), // Session store in Redis per 14-TECHNICAL-DECISIONS.md
  ],
  advanced: {
    cookiePrefix: 'pn-crm',
    defaultCookieAttributes: {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict' as const,
    },
  },
  logger: {
    error: (msg) => logger.error(msg, 'auth'),
    warn: (msg) => logger.warn(msg, 'auth'),
  },
});

export type Session = typeof auth.$Infer.Session;

File: src/lib/auth/client.ts

import { createAuthClient } from 'better-auth/react';

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
});

export const { useSession, signIn, signOut } = authClient;

File: src/app/api/auth/[...all]/route.ts

import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';

export const { GET, POST } = toNextJsHandler(auth);

Step 15: Auth + port context middleware for API routes

File: src/lib/api/helpers.ts

import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { users, userPortRoles, roles, portRoleOverrides } from '@/lib/db/schema';
import { type RolePermissions } from '@/lib/db/schema/users';
import { AppError, errorResponse } from '@/lib/errors';
import { createAuditLog } from '@/lib/audit';
import { logger } from '@/lib/logger';

export interface AuthContext {
  userId: string;
  portId: string;
  portSlug: string;
  isSuperAdmin: boolean;
  permissions: RolePermissions | null; // null = super admin (bypasses all)
  user: {
    email: string;
    name: string;
  };
  ipAddress: string;
  userAgent: string;
}

type RouteHandler<T = unknown> = (
  req: NextRequest,
  ctx: AuthContext,
  params: Record<string, string>,
) => Promise<NextResponse<T>>;

/**
 * Composable API route wrapper.
 * Usage: export const GET = withAuth(withPermission('clients', 'view', handler))
 */
export function withAuth(
  handler: RouteHandler,
): (
  req: NextRequest,
  context: { params: Promise<Record<string, string>> },
) => Promise<NextResponse> {
  return async (req, routeContext) => {
    try {
      // 1. Validate session
      const session = await auth.api.getSession({ headers: req.headers });
      if (!session?.user) {
        return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
      }

      // 2. Load user record
      const user = await db.query.users.findFirst({
        where: eq(users.id, session.user.id),
      });
      if (!user || !user.isActive) {
        return NextResponse.json({ error: 'Account disabled' }, { status: 403 });
      }

      // 3. Determine port context from header or default
      const portId = req.headers.get('X-Port-Id') || user.preferences?.defaultPortId;
      if (!portId && !user.isSuperAdmin) {
        return NextResponse.json({ error: 'Port context required' }, { status: 400 });
      }

      // 4. Load permissions (skip for super admin)
      let permissions: RolePermissions | null = null;
      let portSlug = '';
      if (!user.isSuperAdmin && portId) {
        const portRole = await db.query.userPortRoles.findFirst({
          where: and(eq(userPortRoles.userId, user.id), eq(userPortRoles.portId, portId)),
          with: { role: true, port: true },
        });
        if (!portRole) {
          return NextResponse.json({ error: 'No access to this port' }, { status: 403 });
        }
        permissions = { ...portRole.role.permissions } as RolePermissions;
        portSlug = portRole.port?.slug ?? '';

        // Apply port overrides
        const override = await db.query.portRoleOverrides.findFirst({
          where: and(
            eq(portRoleOverrides.portId, portId),
            eq(portRoleOverrides.roleId, portRole.roleId),
          ),
        });
        if (override) {
          permissions = deepMerge(permissions, override.permissionOverrides) as RolePermissions;
        }
      }

      const ctx: AuthContext = {
        userId: user.id,
        portId: portId!,
        portSlug,
        isSuperAdmin: user.isSuperAdmin,
        permissions,
        user: { email: user.email, name: user.name },
        ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown',
        userAgent: req.headers.get('user-agent') || 'unknown',
      };

      const params = await routeContext.params;
      return await handler(req, ctx, params);
    } catch (error) {
      return errorResponse(error);
    }
  };
}

export function withPermission(
  resource: keyof RolePermissions,
  action: string,
  handler: RouteHandler,
): RouteHandler {
  return async (req, ctx, params) => {
    if (!ctx.isSuperAdmin) {
      const resourcePerms = ctx.permissions?.[resource] as Record<string, boolean> | undefined;
      if (!resourcePerms || !resourcePerms[action]) {
        logger.warn({ userId: ctx.userId, resource, action }, 'Permission denied');
        await createAuditLog({
          userId: ctx.userId,
          portId: ctx.portId,
          action: 'permission_denied',
          entityType: resource,
          entityId: '',
          metadata: { attemptedAction: action },
          ipAddress: ctx.ipAddress,
          userAgent: ctx.userAgent,
        });
        return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
      }
    }
    return handler(req, ctx, params);
  };
}

function deepMerge(
  target: Record<string, unknown>,
  source: Record<string, unknown>,
): Record<string, unknown> {
  const result = { ...target };
  for (const key of Object.keys(source)) {
    if (
      typeof source[key] === 'object' &&
      source[key] !== null &&
      typeof result[key] === 'object' &&
      result[key] !== null
    ) {
      result[key] = deepMerge(
        result[key] as Record<string, unknown>,
        source[key] as Record<string, unknown>,
      );
    } else {
      result[key] = source[key];
    }
  }
  return result;
}

Step 16: Next.js page-level middleware

File: src/middleware.ts

import { NextRequest, NextResponse } from 'next/server';

const PUBLIC_PATHS = ['/login', '/auth/', '/api/auth/', '/api/public/', '/api/health', '/scan'];

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Public paths — no auth check
  if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
    return NextResponse.next();
  }

  // Check for session cookie
  const sessionCookie = req.cookies.get('pn-crm.session_token');
  if (!sessionCookie?.value) {
    // API routes → 401 JSON
    if (pathname.startsWith('/api/')) {
      return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
    }
    // Pages → redirect to login
    const loginUrl = new URL('/login', req.url);
    loginUrl.searchParams.set('redirect', pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};

Step 17: Login page

File: src/app/(auth)/login/page.tsx

  • Full-width centered card on dark navy (#1e2844) background
  • PN logo + "Port Nimara" text + "Marina CRM" subtitle
  • Email + password fields using shadcn Input + Label components
  • React Hook Form + Zod validation:
    const loginSchema = z.object({
      email: z.string().email('Invalid email'),
      password: z.string().min(1, 'Password is required'),
    });
    
  • "Forgot password?" link → /auth/reset-password
  • Rate limiting feedback: show lockout message after 5 failures
  • On success: redirect to /(dashboard)/[portSlug]/ (derive slug from user's port assignment)
  • No "sign up" link — accounts are admin-created only
  • shadcn components used: Card, CardHeader, CardContent, Input, Label, Button, Form

File: src/app/(auth)/set-password/page.tsx

  • Token from URL search params
  • New password + confirm password
  • Zod schema: min 12 chars, 1 uppercase, 1 lowercase, 1 digit, 1 special char
    const passwordSchema = z
      .object({
        password: z
          .string()
          .min(12, 'Minimum 12 characters')
          .regex(/[A-Z]/, 'Must contain uppercase letter')
          .regex(/[a-z]/, 'Must contain lowercase letter')
          .regex(/[0-9]/, 'Must contain digit')
          .regex(/[^A-Za-z0-9]/, 'Must contain special character'),
        confirmPassword: z.string(),
      })
      .refine((data) => data.password === data.confirmPassword, {
        message: 'Passwords must match',
        path: ['confirmPassword'],
      });
    

File: src/app/(auth)/reset-password/page.tsx

  • Email input form → submit → same success message regardless of whether email exists

File: src/app/(auth)/layout.tsx

  • Dark navy background, centered content, no sidebar/topbar
  • Port Nimara branding

Afternoon: Infrastructure (3 hours)

Step 18: Redis connection

pnpm add ioredis

File: src/lib/redis.ts

import Redis from 'ioredis';

import { logger } from '@/lib/logger';

const redisUrl = process.env.REDIS_URL!;

export const redis = new Redis(redisUrl, {
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    const delay = Math.min(times * 200, 2000);
    return delay;
  },
  lazyConnect: true,
});

redis.on('error', (err) => logger.error({ err }, 'Redis connection error'));
redis.on('connect', () => logger.info('Redis connected'));

Step 19: Application-level rate limiter

File: src/lib/rate-limit.ts

import { NextResponse } from 'next/server';

import { redis } from '@/lib/redis';

interface RateLimitConfig {
  windowMs: number;
  max: number;
  keyPrefix: string;
}

interface RateLimitResult {
  allowed: boolean;
  limit: number;
  remaining: number;
  resetAt: number;
}

export async function checkRateLimit(
  identifier: string,
  config: RateLimitConfig,
): Promise<RateLimitResult> {
  const key = `rl:${config.keyPrefix}:${identifier}`;
  const now = Date.now();
  const windowStart = now - config.windowMs;

  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(key, '-inf', windowStart);
  pipeline.zadd(key, now.toString(), `${now}:${Math.random()}`);
  pipeline.zcard(key);
  pipeline.pexpire(key, config.windowMs);
  const results = await pipeline.exec();

  const count = (results?.[2]?.[1] as number) ?? 0;
  const remaining = Math.max(0, config.max - count);

  return {
    allowed: count <= config.max,
    limit: config.max,
    remaining,
    resetAt: now + config.windowMs,
  };
}

export function rateLimitHeaders(result: RateLimitResult): Record<string, string> {
  return {
    'X-RateLimit-Limit': result.limit.toString(),
    'X-RateLimit-Remaining': result.remaining.toString(),
    'X-RateLimit-Reset': Math.ceil(result.resetAt / 1000).toString(),
  };
}

// Pre-configured limiters
export const rateLimiters = {
  auth: { windowMs: 15 * 60 * 1000, max: 5, keyPrefix: 'auth' },
  api: { windowMs: 60 * 1000, max: 120, keyPrefix: 'api' },
  upload: { windowMs: 60 * 1000, max: 10, keyPrefix: 'upload' },
  bulk: { windowMs: 60 * 1000, max: 5, keyPrefix: 'bulk' },
} as const;

Step 20: Structured logger

pnpm add pino pino-pretty

File: src/lib/logger.ts

import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  redact: {
    paths: [
      'password',
      'token',
      'secret',
      'accessKey',
      'secretKey',
      'creditCard',
      '*.password',
      '*.token',
    ],
    censor: '[REDACTED]',
  },
  transport:
    process.env.NODE_ENV !== 'production'
      ? { target: 'pino-pretty', options: { colorize: true } }
      : undefined,
  serializers: {
    err: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
  },
});

Step 21: Error handling

File: src/lib/errors.ts

import { NextResponse } from 'next/server';
import { ZodError } from 'zod';

import { logger } from '@/lib/logger';

export class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string,
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export class NotFoundError extends AppError {
  constructor(entity: string) {
    super(404, `${entity} not found`, 'NOT_FOUND');
  }
}

export class ForbiddenError extends AppError {
  constructor(message = 'Access denied') {
    super(403, message, 'FORBIDDEN');
  }
}

export class ValidationError extends AppError {
  constructor(
    message: string,
    public details?: Array<{ field: string; message: string }>,
  ) {
    super(400, message, 'VALIDATION_ERROR');
  }
}

export class ConflictError extends AppError {
  constructor(message: string) {
    super(409, message, 'CONFLICT');
  }
}

export class RateLimitError extends AppError {
  constructor(public retryAfter: number) {
    super(429, 'Too many requests', 'RATE_LIMITED');
  }
}

export function errorResponse(error: unknown): NextResponse {
  if (error instanceof AppError) {
    const body: Record<string, unknown> = { error: error.message, code: error.code };
    if (error instanceof ValidationError && error.details) {
      body.details = error.details;
    }
    if (error instanceof RateLimitError) {
      body.retryAfter = error.retryAfter;
    }
    return NextResponse.json(body, { status: error.statusCode });
  }

  if (error instanceof ZodError) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        code: 'VALIDATION_ERROR',
        details: error.errors.map((e) => ({
          field: e.path.join('.'),
          message: e.message,
        })),
      },
      { status: 400 },
    );
  }

  // Never leak internals
  logger.error({ err: error }, 'Unhandled error');
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}

Step 22: Audit log module

File: src/lib/audit.ts

import { db } from '@/lib/db';
import { auditLogs } from '@/lib/db/schema';
import { logger } from '@/lib/logger';

interface AuditLogParams {
  userId: string;
  portId: string;
  action:
    | 'create'
    | 'update'
    | 'delete'
    | 'archive'
    | 'restore'
    | 'merge'
    | 'login'
    | 'logout'
    | 'permission_denied'
    | 'revert';
  entityType: string;
  entityId: string;
  fieldChanged?: string;
  oldValue?: Record<string, unknown>;
  newValue?: Record<string, unknown>;
  metadata?: Record<string, unknown>;
  ipAddress: string;
  userAgent: string;
}

export async function createAuditLog(params: AuditLogParams): Promise<void> {
  try {
    await db.insert(auditLogs).values({
      portId: params.portId || null,
      userId: params.userId || null,
      action: params.action,
      entityType: params.entityType,
      entityId: params.entityId,
      fieldChanged: params.fieldChanged,
      oldValue: maskSensitiveFields(params.oldValue),
      newValue: maskSensitiveFields(params.newValue),
      metadata: params.metadata,
      ipAddress: params.ipAddress,
      userAgent: params.userAgent,
    });
  } catch (err) {
    // Audit logging must never crash the parent operation
    logger.error(
      { err, params: { ...params, oldValue: undefined, newValue: undefined } },
      'Failed to write audit log',
    );
  }
}

/**
 * Computes field-level diff between old and new records.
 * Returns an array of { field, oldValue, newValue } for changed fields.
 */
export function diffFields(
  oldRecord: Record<string, unknown>,
  newRecord: Record<string, unknown>,
): Array<{ field: string; oldValue: unknown; newValue: unknown }> {
  const changes: Array<{ field: string; oldValue: unknown; newValue: unknown }> = [];
  for (const key of Object.keys(newRecord)) {
    if (JSON.stringify(oldRecord[key]) !== JSON.stringify(newRecord[key])) {
      changes.push({ field: key, oldValue: oldRecord[key], newValue: newRecord[key] });
    }
  }
  return changes;
}

const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']);

function maskSensitiveFields(data?: Record<string, unknown>): Record<string, unknown> | undefined {
  if (!data) return undefined;
  const masked = { ...data };
  for (const key of Object.keys(masked)) {
    if (SENSITIVE_FIELDS.has(key) && typeof masked[key] === 'string') {
      const val = masked[key] as string;
      masked[key] = val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
    }
  }
  return masked;
}

Step 23: Database utility functions

File: src/lib/db/utils.ts

import { sql, eq, and } from 'drizzle-orm';
import type { PgTable } from 'drizzle-orm/pg-core';

import { db, type Database } from '@/lib/db';

/**
 * Transaction wrapper with automatic rollback on error.
 */
export async function withTransaction<T>(fn: (tx: Database) => Promise<T>): Promise<T> {
  return db.transaction(fn);
}

/**
 * Soft delete: sets archived_at timestamp.
 * The table must have an `archivedAt` column and `portId` column.
 */
export async function softDelete(
  table: PgTable & { archivedAt: any; id: any; portId: any },
  id: string,
  portId: string,
): Promise<void> {
  await db
    .update(table)
    .set({ archivedAt: sql`now()` } as any)
    .where(and(eq(table.id, id), eq(table.portId, portId)));
}

/**
 * Restore: clears archived_at timestamp.
 */
export async function restore(
  table: PgTable & { archivedAt: any; id: any; portId: any },
  id: string,
  portId: string,
): Promise<void> {
  await db
    .update(table)
    .set({ archivedAt: null } as any)
    .where(and(eq(table.id, id), eq(table.portId, portId)));
}

Step 24: MinIO client

pnpm add minio

File: src/lib/minio/index.ts

import { Client } from 'minio';

import { logger } from '@/lib/logger';

export const minioClient = new Client({
  endPoint: process.env.MINIO_ENDPOINT!,
  port: parseInt(process.env.MINIO_PORT!, 10),
  useSSL: process.env.MINIO_USE_SSL === 'true',
  accessKey: process.env.MINIO_ACCESS_KEY!,
  secretKey: process.env.MINIO_SECRET_KEY!,
});

const BUCKET = process.env.MINIO_BUCKET!;

export async function ensureBucket(): Promise<void> {
  try {
    const exists = await minioClient.bucketExists(BUCKET);
    if (!exists) {
      await minioClient.makeBucket(BUCKET);
      logger.info({ bucket: BUCKET }, 'MinIO bucket created');
    }
  } catch (err) {
    logger.error({ err }, 'Failed to ensure MinIO bucket');
    throw err;
  }
}

/**
 * Generate presigned download URL (15-minute expiry per SECURITY-GUIDELINES.md)
 */
export async function getPresignedUrl(objectKey: string, expirySeconds = 900): Promise<string> {
  return minioClient.presignedGetObject(BUCKET, objectKey, expirySeconds);
}

/**
 * Build storage path from components (no user input in path — UUIDs only).
 * Format: {portSlug}/{entity}/{entityId}/{uuid}.{ext}
 */
export function buildStoragePath(
  portSlug: string,
  entity: string,
  entityId: string,
  fileId: string,
  extension: string,
): string {
  return `${portSlug}/${entity}/${entityId}/${fileId}.${extension}`;
}

Step 25: Health check endpoint

File: src/app/api/health/route.ts

import { NextResponse } from 'next/server';

import { db } from '@/lib/db';
import { redis } from '@/lib/redis';
import { minioClient } from '@/lib/minio';
import { sql } from 'drizzle-orm';

export async function GET() {
  const checks: Record<string, 'ok' | 'error'> = {};

  try {
    await db.execute(sql`SELECT 1`);
    checks.postgres = 'ok';
  } catch {
    checks.postgres = 'error';
  }

  try {
    await redis.ping();
    checks.redis = 'ok';
  } catch {
    checks.redis = 'error';
  }

  try {
    await minioClient.bucketExists(process.env.MINIO_BUCKET!);
    checks.minio = 'ok';
  } catch {
    checks.minio = 'error';
  }

  const allOk = Object.values(checks).every((v) => v === 'ok');
  return NextResponse.json(
    { status: allOk ? 'healthy' : 'degraded', checks, timestamp: new Date().toISOString() },
    { status: allOk ? 200 : 503 },
  );
}

Day 3 — Socket.io + BullMQ + Layout Shell (Part 1)

Morning: Real-time + Background Jobs (3 hours)

Step 26: Socket.io server

pnpm add socket.io socket.io-client @socket.io/redis-adapter

File: src/lib/socket/events.ts

Type-safe event definitions matching 11-REALTIME-AND-BACKGROUND-JOBS.md Section 2:

// Server → Client events
export interface ServerToClientEvents {
  // Berth events
  'berth:statusChanged': (payload: {
    berthId: string;
    oldStatus: string;
    newStatus: string;
    triggeredBy: string;
  }) => void;
  'berth:updated': (payload: { berthId: string; changedFields: string[] }) => void;
  'berth:waitingListChanged': (payload: {
    berthId: string;
    action: string;
    entry: unknown;
  }) => void;
  'berth:maintenanceAdded': (payload: { berthId: string; logEntry: unknown }) => void;

  // Client events
  'client:created': (payload: { clientId: string; clientName: string; source: string }) => void;
  'client:updated': (payload: { clientId: string; changedFields: string[] }) => void;
  'client:archived': (payload: { clientId: string }) => void;
  'client:restored': (payload: { clientId: string }) => void;
  'client:merged': (payload: { survivingId: string; mergedId: string }) => void;
  'client:noteAdded': (payload: {
    clientId: string;
    noteId: string;
    authorName: string;
    preview: string;
  }) => void;
  'client:duplicateDetected': (payload: {
    clientAId: string;
    clientBId: string;
    score: number;
    reason: string;
  }) => void;

  // Interest events
  'interest:created': (payload: {
    interestId: string;
    clientId: string;
    berthId: string | null;
    source: string;
  }) => void;
  'interest:updated': (payload: { interestId: string; changedFields: string[] }) => void;
  'interest:stageChanged': (payload: {
    interestId: string;
    oldStage: string;
    newStage: string;
    clientName: string;
    berthNumber: string;
  }) => void;
  'interest:berthLinked': (payload: { interestId: string; berthId: string }) => void;
  'interest:berthUnlinked': (payload: { interestId: string; berthId: string }) => void;
  'interest:archived': (payload: { interestId: string }) => void;
  'interest:noteAdded': (payload: {
    interestId: string;
    noteId: string;
    authorName: string;
    preview: string;
  }) => void;
  'interest:recommendationsGenerated': (payload: {
    interestId: string;
    count: number;
    topBerthId: string;
  }) => void;
  'interest:recommendationAdded': (payload: {
    interestId: string;
    berthId: string;
    source: string;
    matchScore: number;
  }) => void;
  'interest:leadCategoryChanged': (payload: {
    interestId: string;
    oldCategory: string;
    newCategory: string;
    auto: boolean;
  }) => void;

  // Document events
  'document:created': (payload: { documentId: string; type: string; interestId: string }) => void;
  'document:sent': (payload: { documentId: string; type: string; signerCount: number }) => void;
  'document:signed': (payload: {
    documentId: string;
    signerName: string;
    signerRole: string;
    remainingSigners: number;
  }) => void;
  'document:completed': (payload: {
    documentId: string;
    type: string;
    interestId: string;
    clientName: string;
  }) => void;
  'document:expired': (payload: { documentId: string }) => void;
  'document:reminderSent': (payload: { documentId: string; recipientEmail: string }) => void;

  // Financial events
  'expense:created': (payload: {
    expenseId: string;
    amount: number;
    currency: string;
    category: string;
  }) => void;
  'expense:updated': (payload: { expenseId: string; changedFields: string[] }) => void;
  'invoice:created': (payload: {
    invoiceId: string;
    invoiceNumber: string;
    total: number;
    clientName: string;
  }) => void;
  'invoice:sent': (payload: {
    invoiceId: string;
    invoiceNumber: string;
    recipientEmail: string;
  }) => void;
  'invoice:paid': (payload: { invoiceId: string; invoiceNumber: string; amount: number }) => void;
  'invoice:overdue': (payload: {
    invoiceId: string;
    invoiceNumber: string;
    daysPastDue: number;
  }) => void;

  // Reminder & Calendar events
  'reminder:created': (payload: {
    reminderId: string;
    title: string;
    assignedTo: string;
    dueAt: string;
  }) => void;
  'reminder:updated': (payload: { reminderId: string; changedFields: string[] }) => void;
  'reminder:completed': (payload: {
    reminderId: string;
    title: string;
    completedBy: string;
  }) => void;
  'reminder:overdue': (payload: { reminderId: string; title: string; dueAt: string }) => void;
  'reminder:snoozed': (payload: { reminderId: string; snoozedUntil: string }) => void;
  'calendar:synced': (payload: { eventCount: number; lastSyncAt: string }) => void;
  'calendar:disconnected': (payload: { reason: string }) => void;

  // Notification events
  'notification:new': (payload: {
    notificationId: string;
    type: string;
    title: string;
    description: string;
    link: string;
  }) => void;
  'notification:unreadCount': (payload: { count: number }) => void;

  // System events
  'system:alert': (payload: { alertType: string; message: string; severity: string }) => void;
  'system:jobFailed': (payload: { queueName: string; jobId: string; error: string }) => void;
  'registration:new': (payload: {
    clientId: string;
    interestId: string;
    clientName: string;
    berthNumber: string;
  }) => void;

  // File events
  'file:uploaded': (payload: {
    fileId: string;
    filename: string;
    clientId: string;
    category: string;
  }) => void;
  'file:deleted': (payload: { fileId: string; filename: string }) => void;
}

// Client → Server events (minimal — most actions go through REST API)
export interface ClientToServerEvents {
  'join:entity': (payload: { type: 'berth' | 'client' | 'interest'; id: string }) => void;
  'leave:entity': (payload: { type: 'berth' | 'client' | 'interest'; id: string }) => void;
}

File: src/lib/socket/server.ts

import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import type { Server as HTTPServer } from 'node:http';

import { redis } from '@/lib/redis';
import { auth } from '@/lib/auth';
import { logger } from '@/lib/logger';
import type { ServerToClientEvents, ClientToServerEvents } from './events';

let io: Server<ClientToServerEvents, ServerToClientEvents> | null = null;

export function initSocketServer(
  httpServer: HTTPServer,
): Server<ClientToServerEvents, ServerToClientEvents> {
  const pubClient = redis.duplicate();
  const subClient = redis.duplicate();

  io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer, {
    path: '/socket.io/',
    adapter: createAdapter(pubClient, subClient),
    cors: {
      origin: process.env.APP_URL,
      credentials: true,
    },
    connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },
    maxHttpBufferSize: 1e6, // 1MB message limit per SECURITY-GUIDELINES.md
  });

  // Auth middleware — validate session cookie
  io.use(async (socket, next) => {
    try {
      const cookie = socket.handshake.headers.cookie;
      if (!cookie) return next(new Error('Authentication required'));

      // Parse session from cookie
      const session = await auth.api.getSession({
        headers: new Headers({ cookie }),
      });
      if (!session?.user) return next(new Error('Invalid session'));

      // Enforce max 10 connections per user
      const userSockets = await io!.in(`user:${session.user.id}`).fetchSockets();
      if (userSockets.length >= 10) {
        return next(new Error('Maximum connections reached'));
      }

      socket.data = {
        userId: session.user.id,
        portId: socket.handshake.auth.portId,
      };
      next();
    } catch {
      next(new Error('Authentication failed'));
    }
  });

  // Connection handler
  io.on('connection', (socket) => {
    const { userId, portId } = socket.data;
    logger.debug({ userId, portId }, 'Socket connected');

    // Auto-join rooms
    socket.join(`user:${userId}`);
    if (portId) socket.join(`port:${portId}`);

    // Entity-level room management
    socket.on('join:entity', ({ type, id }) => {
      socket.join(`${type}:${id}`);
    });
    socket.on('leave:entity', ({ type, id }) => {
      socket.leave(`${type}:${id}`);
    });

    // Idle timeout (30 seconds per SECURITY-GUIDELINES.md)
    let idleTimer = setTimeout(() => socket.disconnect(), 30_000);
    socket.onAny(() => {
      clearTimeout(idleTimer);
      idleTimer = setTimeout(() => socket.disconnect(), 30_000);
    });

    socket.on('disconnect', () => {
      clearTimeout(idleTimer);
      logger.debug({ userId }, 'Socket disconnected');
    });
  });

  return io;
}

export function getIO(): Server<ClientToServerEvents, ServerToClientEvents> {
  if (!io) throw new Error('Socket.io not initialized');
  return io;
}

/**
 * Emit an event to a specific room. Used by service layer after mutations.
 */
export function emitToRoom<E extends keyof ServerToClientEvents>(
  room: string,
  event: E,
  ...args: Parameters<ServerToClientEvents[E]>
): void {
  if (!io) return;
  io.to(room).emit(event, ...args);
}

File: src/providers/socket-provider.tsx

'use client';

import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { io, type Socket } from 'socket.io-client';

import { useSession } from '@/lib/auth/client';
import { usePortStore } from '@/stores/ui-store';

const SocketContext = createContext<Socket | null>(null);

export function SocketProvider({ children }: { children: ReactNode }) {
  const { data: session } = useSession();
  const currentPortId = usePortStore((s) => s.currentPortId);
  const [socket, setSocket] = useState<Socket | null>(null);

  useEffect(() => {
    if (!session?.user || !currentPortId) return;

    const s = io(process.env.NEXT_PUBLIC_APP_URL!, {
      path: '/socket.io/',
      withCredentials: true,
      auth: { portId: currentPortId },
      transports: ['websocket', 'polling'],
    });

    s.on('connect', () => setSocket(s));
    s.on('disconnect', () => setSocket(null));

    return () => {
      s.disconnect();
      setSocket(null);
    };
  }, [session?.user, currentPortId]);

  return (
    <SocketContext.Provider value={socket}>
      {children}
    </SocketContext.Provider>
  );
}

export function useSocket() {
  return useContext(SocketContext);
}

Step 27: BullMQ setup

pnpm add bullmq

File: src/lib/queue/index.ts

import { Queue } from 'bullmq';

import { redis } from '@/lib/redis';

// 10 queues matching 11-REALTIME-AND-BACKGROUND-JOBS.md Section 3.1
const QUEUE_CONFIGS = {
  email: { concurrency: 5, maxAttempts: 5 },
  documents: { concurrency: 3, maxAttempts: 5 },
  notifications: { concurrency: 10, maxAttempts: 3 },
  import: { concurrency: 1, maxAttempts: 1 },
  export: { concurrency: 2, maxAttempts: 3 },
  reports: { concurrency: 1, maxAttempts: 3 },
  webhooks: { concurrency: 5, maxAttempts: 3 },
  maintenance: { concurrency: 1, maxAttempts: 3 },
  ai: { concurrency: 2, maxAttempts: 3 },
  bulk: { concurrency: 2, maxAttempts: 3 },
} as const;

export type QueueName = keyof typeof QUEUE_CONFIGS;

const queues = new Map<QueueName, Queue>();

export function getQueue(name: QueueName): Queue {
  let queue = queues.get(name);
  if (!queue) {
    queue = new Queue(name, {
      connection: redis,
      defaultJobOptions: {
        attempts: QUEUE_CONFIGS[name].maxAttempts,
        backoff: { type: 'exponential', delay: 1000 },
        removeOnComplete: { age: 24 * 3600 },
        removeOnFail: { age: 7 * 24 * 3600 },
      },
    });
    queues.set(name, queue);
  }
  return queue;
}

export { QUEUE_CONFIGS };

File: src/lib/queue/scheduler.ts

import { getQueue } from './index';
import { logger } from '@/lib/logger';

/**
 * Register all recurring jobs from 11-REALTIME-AND-BACKGROUND-JOBS.md Section 3.2
 * Called once on server startup.
 */
export async function registerRecurringJobs(): Promise<void> {
  const recurring = [
    { queue: 'documents', name: 'signature-poll', pattern: '0 */6 * * *' },
    { queue: 'notifications', name: 'reminder-check', pattern: '0 * * * *' },
    { queue: 'notifications', name: 'reminder-overdue-check', pattern: '*/15 * * * *' },
    { queue: 'maintenance', name: 'calendar-sync', pattern: '*/30 * * * *' },
    { queue: 'notifications', name: 'invoice-overdue-check', pattern: '0 8 * * *' },
    { queue: 'notifications', name: 'tenure-expiry-check', pattern: '0 8 * * *' },
    { queue: 'maintenance', name: 'currency-refresh', pattern: '0 */6 * * *' },
    { queue: 'maintenance', name: 'database-backup', pattern: '0 2 * * *' },
    { queue: 'maintenance', name: 'backup-cleanup', pattern: '0 3 * * 0' },
    { queue: 'maintenance', name: 'session-cleanup', pattern: '0 4 * * *' },
    { queue: 'reports', name: 'report-scheduler', pattern: '* * * * *' },
    { queue: 'maintenance', name: 'temp-file-cleanup', pattern: '0 5 * * *' },
    { queue: 'maintenance', name: 'form-expiry-check', pattern: '0 * * * *' },
  ] as const;

  for (const job of recurring) {
    const queue = getQueue(job.queue as any);
    await queue.upsertJobScheduler(
      job.name,
      { pattern: job.pattern },
      { data: {}, name: job.name },
    );
    logger.info(
      { queue: job.queue, job: job.name, pattern: job.pattern },
      'Registered recurring job',
    );
  }
}

File: src/lib/queue/workers/ — one stub file per queue (e.g., email.ts, documents.ts, etc.):

// src/lib/queue/workers/email.ts
import { Worker, type Job } from 'bullmq';
import { redis } from '@/lib/redis';
import { logger } from '@/lib/logger';

export const emailWorker = new Worker(
  'email',
  async (job: Job) => {
    logger.info({ jobId: job.id, jobName: job.name }, 'Processing email job');
    // TODO: implement in Layer 2
  },
  {
    connection: redis,
    concurrency: 5,
  },
);

emailWorker.on('failed', (job, err) => {
  logger.error({ jobId: job?.id, err }, 'Email job failed');
});

Afternoon: Layout Shell Start (3 hours) — continues into Day 4

Step 28: Install shadcn/ui + core components

pnpm dlx shadcn@latest init

Configure: New York style, Tailwind CSS, navy theme.

Install all core components from 14-TECHNICAL-DECISIONS.md Section 2.2:

pnpm dlx shadcn@latest add button input label select textarea checkbox radio-group switch dialog sheet dropdown-menu command tabs table card badge avatar tooltip popover calendar form skeleton separator scroll-area alert-dialog accordion breadcrumb navigation-menu pagination progress slider sonner

Step 29: Tailwind config with design tokens

File: tailwind.config.ts

Apply ALL tokens from 15-DESIGN-TOKENS.md:

  • Brand colors with full tint ladders (navy, brand, sage, mint, teal, purple)
  • Semantic color variables via CSS custom properties
  • Shadows using rgba(30, 40, 68, ...) values
  • Typography: Inter (font-sans), Georgia (font-serif), JetBrains Mono (font-mono)
  • Border radius variable

File: src/app/globals.css

All CSS custom properties from 15-DESIGN-TOKENS.md Section 5 — light mode defaults and dark mode overrides via [data-theme="dark"] or .dark class. Full HSL values for shadcn/ui compatibility.


Day 4 — Layout Shell (Part 2) + Security + Verification

Morning: Layout Components (3 hours)

Step 30: Root layout + providers

File: src/app/layout.tsx

import type { Metadata } from 'next';
import { Inter, JetBrains_Mono } from 'next/font/google';
import { Toaster } from '@/components/ui/sonner';
import './globals.css';

const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' });

export const metadata: Metadata = {
  title: 'Port Nimara CRM',
  description: 'Marina management system',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
        {children}
        <Toaster richColors position="top-right" />
      </body>
    </html>
  );
}

File: src/app/(dashboard)/layout.tsx

import { redirect } from 'next/navigation';
import { headers } from 'next/headers';

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { QueryProvider } from '@/providers/query-provider';
import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';

export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session?.user) redirect('/login');

  // Load user's port assignments for PortProvider
  const portRoles = await db.query.userPortRoles.findMany({
    where: eq(userPortRoles.userId, session.user.id),
    with: { port: true, role: true },
  });

  return (
    <QueryProvider>
      <PortProvider ports={portRoles.map(pr => pr.port)} defaultPortId={portRoles[0]?.port.id}>
        <SocketProvider>
          <div className="flex h-screen overflow-hidden">
            <Sidebar ports={portRoles} />
            <div className="flex-1 flex flex-col overflow-hidden">
              <Topbar />
              <main className="flex-1 overflow-y-auto bg-background p-6">
                {children}
              </main>
            </div>
          </div>
        </SocketProvider>
      </PortProvider>
    </QueryProvider>
  );
}

File: src/components/layout/sidebar.tsx

  • Dark navy background (bg-[#1e2844])
  • Logo area: "PN" mark + "Port Nimara" + "Marina CRM"
  • Navigation sections per 13-UI-PAGE-MAP.md:
    • Main: Dashboard, Clients, Interests, Berths
    • Financial: Expenses, Invoices
    • Operations: Files, Email, Reminders
    • Admin (expandable, permission-gated): Users, Roles, Ports, Audit, Settings, etc.
  • Active state: border-l-2 border-brand bg-sidebar-active text-white
  • Hover state: bg-[#171f35]
  • Icons: Lucide React (LayoutDashboard, Users, Bookmark, Anchor, Receipt, FileText, FolderOpen, Mail, Bell, Settings, Shield)
  • Collapsible: persisted via Zustand sidebarCollapsed
  • Mobile: Sheet/drawer triggered by hamburger
  • User footer: avatar + name + role badge
  • shadcn components: Sheet, ScrollArea, Tooltip, Badge, Avatar, Separator

File: src/components/layout/topbar.tsx

  • Page title (derived from route params)
  • Search box: Button with ⌘K label → placeholder for Command palette (implemented in L3)
  • Notification bell: Button with Badge unread count dot → placeholder panel
  • "+ New" dropdown: DropdownMenu with context-aware options (New Client, New Interest, New Expense)
  • Port switcher (only if 2+ ports): Select dropdown with port names
  • User menu: DropdownMenu with Profile, Dark Mode toggle, Scratchpad, Logout
  • shadcn components: Button, DropdownMenu, Badge, Avatar, Separator

File: src/components/layout/port-switcher.tsx

  • Hidden when user has only 1 port
  • Select dropdown showing port name
  • On change: updates Zustand currentPortId → invalidates all TanStack Query caches → Socket.io reconnects to new port room

File: src/components/layout/breadcrumbs.tsx

  • Auto-generated from usePathname() route segments
  • Maps [portSlug] to port name
  • Clickable segments using shadcn Breadcrumb component

Step 31: Zustand UI store

pnpm add zustand

File: src/stores/ui-store.ts

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface UIStore {
  sidebarCollapsed: boolean;
  currentPortId: string | null;
  currentPortSlug: string | null;
  darkMode: boolean;
  toggleSidebar: () => void;
  setPort: (portId: string, portSlug: string) => void;
  toggleDarkMode: () => void;
}

export const useUIStore = create<UIStore>()(
  persist(
    (set) => ({
      sidebarCollapsed: false,
      currentPortId: null,
      currentPortSlug: null,
      darkMode: false,
      toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
      setPort: (portId, portSlug) => set({ currentPortId: portId, currentPortSlug: portSlug }),
      toggleDarkMode: () => set((s) => ({ darkMode: !s.darkMode })),
    }),
    {
      name: 'pn-crm-ui',
      partialize: (state) => ({
        sidebarCollapsed: state.sidebarCollapsed,
        currentPortId: state.currentPortId,
        currentPortSlug: state.currentPortSlug,
        darkMode: state.darkMode,
      }),
    },
  ),
);

// Alias for port-specific access
export const usePortStore = useUIStore;

Step 32: TanStack Query provider

pnpm add @tanstack/react-query @tanstack/react-query-devtools

File: src/providers/query-provider.tsx

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';

export function QueryProvider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 30 * 1000,
            retry: 1,
            refetchOnWindowFocus: false,
          },
          mutations: {
            onError: (error) => {
              console.error('Mutation error:', error);
            },
          },
        },
      }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      {process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} />}
    </QueryClientProvider>
  );
}

Step 33: Base UI patterns

File: src/components/shared/page-header.tsx — consistent page header with title, description, action buttons

File: src/components/shared/empty-state.tsx — "no data" pattern with icon, title, description, optional CTA

File: src/components/shared/loading-skeleton.tsx — reusable skeleton patterns (table, card, form)

File: src/components/shared/confirmation-dialog.tsx — destructive action confirmation using shadcn AlertDialog

Step 34: Placeholder pages

Create minimal placeholder pages for every route. Each renders <PageHeader> + <EmptyState> with "Coming in Layer X" message and correct breadcrumbs.

Pages under src/app/(dashboard)/[portSlug]/:

  • page.tsx (dashboard)
  • clients/page.tsx
  • interests/page.tsx
  • berths/page.tsx
  • expenses/page.tsx
  • invoices/page.tsx
  • documents/page.tsx
  • email/page.tsx
  • reminders/page.tsx
  • reports/page.tsx
  • settings/profile/page.tsx
  • settings/notifications/page.tsx
  • settings/calendar/page.tsx
  • admin/page.tsx
  • admin/users/page.tsx
  • admin/roles/page.tsx
  • admin/ports/page.tsx
  • admin/audit/page.tsx
  • admin/settings/page.tsx
  • admin/webhooks/page.tsx
  • admin/reports/page.tsx
  • admin/templates/page.tsx
  • admin/forms/page.tsx
  • admin/tags/page.tsx
  • admin/import/page.tsx
  • admin/monitoring/page.tsx
  • admin/backup/page.tsx
  • admin/custom-fields/page.tsx
  • admin/onboarding/page.tsx

Afternoon: Security Baseline + Nginx + Verification (3 hours)

Step 35: Nginx configuration

File: nginx/nginx.conf

Full configuration matching SECURITY-GUIDELINES.md Section 6:

  • TLS 1.3 only
  • All security headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP, Permissions-Policy)
  • Rate limiting zones: auth (5r/m), general (30r/s), api (60r/s), upload (10r/m)
  • Proxy to crm-app:3000
  • WebSocket upgrade for /socket.io/
  • CORS for /api/public/ (allow portnimara.com only)
  • HTTP → HTTPS redirect
  • Request body size limits (1MB JSON, 50MB uploads)

Step 36: Constants file

File: src/lib/constants.ts

// Pipeline stages (from 09-BUSINESS-RULES.md)
export const PIPELINE_STAGES = [
  'open',
  'details_sent',
  'in_communication',
  'visited',
  'signed_eoi_nda',
  'ten_percent_deposit',
  'contract',
  'completed',
] as const;

export type PipelineStage = (typeof PIPELINE_STAGES)[number];

// Berth statuses
export const BERTH_STATUSES = [
  'available',
  'under_offer',
  'sold',
  'maintenance',
  'reserved',
] as const;
export type BerthStatus = (typeof BERTH_STATUSES)[number];

// Lead categories
export const LEAD_CATEGORIES = ['hot', 'warm', 'cold', 'dormant'] as const;
export type LeadCategory = (typeof LEAD_CATEGORIES)[number];

// Invoice statuses
export const INVOICE_STATUSES = ['draft', 'sent', 'paid', 'overdue', 'cancelled'] as const;
export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];

// Expense categories
export const EXPENSE_CATEGORIES = [
  'food_beverage',
  'transportation',
  'accommodation',
  'supplies',
  'maintenance',
  'utilities',
  'professional_services',
  'entertainment',
  'marketing',
  'office',
  'other',
] as const;
export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number];

// Document types
export const DOCUMENT_TYPES = ['eoi', 'nda', 'contract', 'addendum', 'other'] as const;
export type DocumentType = (typeof DOCUMENT_TYPES)[number];

// Reminder priorities
export const REMINDER_PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const;
export type ReminderPriority = (typeof REMINDER_PRIORITIES)[number];

// Client sources
export const CLIENT_SOURCES = [
  'website',
  'manual',
  'referral',
  'broker',
  'event',
  'other',
] as const;
export type ClientSource = (typeof CLIENT_SOURCES)[number];

// Notification types
export const NOTIFICATION_TYPES = [
  'reminder_due',
  'reminder_overdue',
  'new_registration',
  'eoi_signature_event',
  'new_email',
  'duplicate_alert',
  'invoice_overdue',
  'waiting_list',
  'system_alert',
  'follow_up_created',
  'tenure_expiring',
] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];

// File MIME type allowlist (SECURITY-GUIDELINES.md Section 10)
export const ALLOWED_MIME_TYPES = new Set([
  'image/jpeg',
  'image/png',
  'image/gif',
  'image/webp',
  'application/pdf',
  'application/msword',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  'application/vnd.ms-excel',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);

export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
export const MAX_JSON_BODY_SIZE = 1 * 1024 * 1024; // 1MB
export const PRESIGNED_URL_EXPIRY = 900; // 15 minutes

Step 37: Custom server entry point

File: src/server.ts

This is the custom entry point that boots Next.js + Socket.io together in development. In production, the standalone Next.js server.js handles HTTP, and crm-worker handles BullMQ separately.

import { createServer } from 'node:http';
import next from 'next';

import { initSocketServer } from '@/lib/socket/server';
import { registerRecurringJobs } from '@/lib/queue/scheduler';
import { ensureBucket } from '@/lib/minio';
import { logger } from '@/lib/logger';

const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = parseInt(process.env.PORT || '3000', 10);

async function main() {
  const app = next({ dev, hostname, port });
  const handle = app.getRequestHandler();

  await app.prepare();

  const httpServer = createServer(handle);

  // Initialize Socket.io on the same HTTP server
  initSocketServer(httpServer);
  logger.info('Socket.io initialized');

  // Ensure MinIO bucket exists
  await ensureBucket();
  logger.info('MinIO bucket verified');

  // Register recurring BullMQ jobs (dev only — production uses crm-worker)
  if (dev) {
    await registerRecurringJobs();
    // Import workers to start processing
    await import('@/lib/queue/workers/email');
    await import('@/lib/queue/workers/documents');
    await import('@/lib/queue/workers/notifications');
    await import('@/lib/queue/workers/maintenance');
    // ... other workers
    logger.info('BullMQ workers started (dev mode)');
  }

  httpServer.listen(port, () => {
    logger.info({ port, dev }, `Server ready at http://${hostname}:${port}`);
  });
}

main().catch((err) => {
  logger.fatal(err, 'Failed to start server');
  process.exit(1);
});

Step 38: Final verification

Run the full verification checklist:

# Type check
pnpm tsc --noEmit

# Lint
pnpm eslint src/

# Docker
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build

# Verify DB tables
pnpm drizzle-kit studio  # Visual inspection of all 49 tables

# Seed
pnpm tsx src/lib/db/seed.ts

# Start dev server
pnpm tsx src/server.ts

# Verify:
# - Login page renders at /login
# - Can log in with seeded admin
# - Dashboard layout renders with sidebar
# - All placeholder pages accessible
# - Socket.io connects (check browser console)
# - BullMQ queues initialized (check server logs)
# - Health endpoint returns 200 at /api/health

3. Code-Ready Details

API Route Middleware Chain

Every API route under /api/v1/ follows this pattern:

Request → Next.js middleware (session cookie check)
       → withAuth() (load user, port, permissions)
       → withPermission(resource, action) (RBAC check)
       → rate limit check (Redis sliding window)
       → Zod input validation
       → handler (business logic)
       → audit log write
       → response

State Management Per Page

Page Server State (TanStack Query) Client State (Zustand) URL State
Dashboard ['dashboard', portId] sidebarCollapsed none
Client List ['clients', portId, filters] sidebarCollapsed ?search=&source=&page=
Client Detail ['clients', portId, clientId] activeTab ?tab=overview
All list pages [entity, portId, filters] sidebarCollapsed, viewMode ?page=&sort=&filter=

shadcn Components Used in L0

Component Where Used
Button Login form, sidebar nav, topbar actions, page headers
Input, Label Login form, password forms
Card Login page, empty states
Avatar, Badge Sidebar user footer, topbar
DropdownMenu Topbar "+ New", user menu
Sheet Mobile sidebar drawer
ScrollArea Sidebar navigation
Separator Sidebar sections
Tooltip Collapsed sidebar icons
Breadcrumb All pages (auto-generated)
Skeleton Loading states
AlertDialog Confirmation dialogs
Sonner (Toast) Notifications, errors
Form Login, set-password, reset-password

CSS / Tailwind Patterns

  • Sidebar: w-64 bg-[#1e2844] text-sidebar-text (expanded), w-16 (collapsed)
  • Content area: bg-background (white light mode, #131a2c dark mode)
  • Page headers: text-2xl font-semibold text-text-primary
  • Cards: bg-card rounded-lg border shadow-sm
  • Status badges: bg-success-bg text-success border-success-border (and warning/error/info variants)
  • Focus rings: ring-2 ring-brand ring-offset-2
  • Transitions: sidebar collapse transition-all duration-200, page transitions via motion-safe:animate-in

4. Acceptance Criteria

  1. docker compose -f docker-compose.yml -f docker-compose.dev.yml up starts postgres, redis, and crm-app successfully
  2. All 49 database tables exist with correct schemas, constraints, indexes, and pgcrypto extension
  3. Seed data inserted: 1 port, 5 system roles, 1 super admin user
  4. Login page renders at /login with Port Nimara branding (dark navy, PN logo)
  5. Can log in with super admin credentials → redirected to /(dashboard)/port-nimara/
  6. Session persists across page refreshes (httpOnly cookie, Redis-backed)
  7. Protected routes redirect to /login when not authenticated
  8. API routes return 401 JSON when not authenticated
  9. Sidebar renders all navigation sections with correct icons
  10. All ~29 placeholder pages load with correct breadcrumbs and "Coming in Layer X" empty states
  11. Port switcher hidden (single port mode) — visible only with 2+ ports
  12. Socket.io connects on login, auto-joins port:{portId} and user:{userId} rooms
  13. All 10 BullMQ queues initialized (visible in server logs)
  14. All 13 recurring jobs registered (visible in server logs)
  15. MinIO bucket exists and accessible (/api/health reports minio: ok)
  16. Health endpoint at /api/health returns { status: "healthy", checks: { postgres: "ok", redis: "ok", minio: "ok" } }
  17. Structured logging active (pino JSON output in production, pretty-printed in dev)
  18. Nginx serves over HTTPS with all security headers (HSTS, CSP, X-Frame-Options, etc.)
  19. Application-level rate limiter functional (Redis sliding window)
  20. ESLint passes with zero errors, TypeScript strict mode passes with zero errors
  21. No secrets in source code — .env.example has placeholders only, pre-commit hook catches accidental commits
  22. Audit log middleware functional — login event written to audit_logs table
  23. Zod env validation fails fast on missing/invalid environment variables
  24. Dark mode toggle in user menu switches theme (CSS variables swap)
  25. Mobile responsive: sidebar collapses to hamburger drawer below 768px

5. Self-Review Checklist

Before calling Layer 0 done:

  • Security: All env vars externalized, no hardcoded secrets, pgcrypto enabled, TLS 1.3 configured
  • Auth: Better Auth + Redis sessions, Argon2id hashing, 12-char minimum password, CSRF protection, rate-limited login
  • Port scoping: Every query helper includes portId, middleware extracts port from session context
  • RBAC: 5 system roles match 10-AUTH-AND-PERMISSIONS.md, permission check helper works with JSON permission map
  • Audit: createAuditLog() called on login/logout, sensitive fields masked, append-only
  • Error handling: AppError hierarchy, Zod validation errors formatted as { error, details }, no stack traces leak to client
  • Rate limiting: Both nginx (per-IP) and application (per-user Redis sliding window) active
  • Type safety: Zero any types, strict mode, all Drizzle schemas typed, all API responses typed
  • Docker: Multi-stage build, non-root user, health checks on all services, no exposed internal ports
  • Logging: Pino with redaction rules, no PII in logs, log levels configurable via env
  • Real-time: Socket.io connects, authenticates, joins rooms, idle timeout configured
  • Background jobs: All 10 queues created, all 13 recurring jobs registered, exponential backoff configured
  • UI: Sidebar matches mockup-A design, Inter font loaded, design tokens applied, responsive breakpoints working
  • Developer experience: ESLint + Prettier + lint-staged + husky, TypeScript strict, hot reload works, Drizzle Studio accessible
  • Schema completeness: All 49 tables from 07-DATABASE-SCHEMA.md present with correct columns, types, constraints, indexes, and Drizzle relation definitions

Estimated File Count

Layer 0 produces approximately 8595 files:

  • Config files: ~10 (tsconfig, eslint, prettier, drizzle, next.config, docker, nginx, husky, lint-staged, .env.example)
  • Schema files: 12 (10 domain + relations + index)
  • Auth: 4 (server, client, permissions, api catch-all)
  • Infrastructure: 8 (redis, rate-limit, logger, errors, audit, minio, env, constants)
  • Socket.io: 4 (server, events, rooms, provider)
  • BullMQ: 13 (index, scheduler, 10 worker stubs, types)
  • Layout components: 5 (sidebar, topbar, port-switcher, breadcrumbs, dashboard layout)
  • Shared components: 4 (page-header, empty-state, loading-skeleton, confirmation-dialog)
  • Providers: 3 (query, socket, port)
  • Stores: 1 (ui-store)
  • Pages: ~32 (3 auth + 29 placeholder dashboard pages + not-found)
  • Server: 1 (custom server.ts)
  • Seed: 1
  • API routes: 2 (auth catch-all, health)

Codex Addenda — Merged from Competing Plan Review

The following items are cherry-picked from the Codex competing plan and should be incorporated during implementation. They represent architectural patterns and corrections that complement the Claude Code base plan.

1. Execution Spine Framing

Frame L0 not as "setup work" but as building the execution spine that all subsequent layers hang from. The reusable contracts produced here — defineRoute, RequestContext, requirePermission, auditLog, buildStorageKey, queue registry, and socket emitter — are the multiplier for 250+ endpoints. Pages stay minimal until those contracts exist.

2. Table Count Correction

The locked schema defines 51 application tables plus Better Auth-managed auth tables. Plans referencing "49 tables" should be corrected. The coding agent will drift if the count is wrong.

3. Session Storage Resolution

Standardize on PostgreSQL as the Better Auth session source of truth. Redis is for cache, rate limits, queues, and Socket.io only. This resolves the contradiction across foundation docs.

4. Health Response Schema

Add a typed health check response schema:

export const healthResponseSchema = z.object({
  status: z.enum(['healthy', 'degraded', 'down']),
  services: z.array(
    z.object({
      name: z.string(),
      status: z.enum(['healthy', 'degraded', 'down']),
      latencyMs: z.number().nullable(),
      detail: z.string().optional(),
    }),
  ),
});

5. Storage Key Builder Signature

Adopt Codex's explicit entity enum in buildStorageKey:

export function buildStorageKey(input: {
  portSlug: string;
  entity: 'clients' | 'expenses' | 'invoices' | 'documents' | 'general';
  entityId: string;
  extension: string;
}): string;

Storage keys use UUID filenames only: {portSlug}/{entity}/{entityId}/{uuid}.{ext}.

6. Graceful Degradation Edge Cases

  • Redis unavailable on boot: App still starts, but queues, rate limiting, and sockets are marked degraded in health check.
  • MinIO unavailable on boot: File-dependent routes are disabled via startup status, not silent failures.
  • Duplicate socket connections: Beyond 10 per user are rejected.
  • Presigned URLs: Expire after 15 minutes and are never cached client-side in persisted state.
  • User has no assigned ports: Redirect to a blocked access page, not the dashboard.
  • One active port only: Port switcher component never mounts.

7. Security: Queue Job Payloads

Never put secrets or raw credentials into BullMQ job payloads — enqueue record IDs only.

8. Docker Acceptance Criteria

docker compose up must start crm-app, postgres, redis, and nginx without manual patching. This should be verified as part of L0 acceptance.