# L0 — Foundation (Competing Plan — Claude Code) **Duration:** Days 6–9 (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** ```bash 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` ```typescript 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; 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` ```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** ```bash pnpm add -D prettier eslint-config-prettier lint-staged ``` **File:** `.eslintrc.json` ```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` ```json { "semi": true, "singleQuote": true, "trailingComma": "all", "tabWidth": 2, "printWidth": 100 } ``` **File:** `.lintstagedrc.json` ```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). ```bash pnpm add -D husky pnpm exec husky init ``` **File:** `.husky/pre-commit` ```bash #!/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` ```yaml 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) ```yaml 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` ```sql CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; ``` **File:** `Dockerfile` ```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` ```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 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` ```typescript 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** ```bash pnpm add drizzle-orm postgres pnpm add -D drizzle-kit ``` **Step 9: Drizzle config** **File:** `drizzle.config.ts` ```typescript 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` ```typescript 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` ```typescript 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().default({}), branding: jsonb('branding').$type().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` ```typescript 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().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; }; 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().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>().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.ts` — `clients`, `client_contacts`, `client_relationships`, `client_notes`, `client_tags`, `client_merge_log` **File:** `src/lib/db/schema/interests.ts` — `interests`, `interest_notes`, `interest_tags` **File:** `src/lib/db/schema/berths.ts` — `berths`, `berth_map_data`, `berth_recommendations`, `berth_waiting_list`, `berth_maintenance_log`, `berth_tags` **File:** `src/lib/db/schema/documents.ts` — `documents`, `document_signers`, `document_events`, `document_templates`, `form_templates`, `form_submissions` **File:** `src/lib/db/schema/financial.ts` — `expenses`, `invoices`, `invoice_line_items`, `invoice_expenses` **File:** `src/lib/db/schema/email.ts` — `email_accounts`, `email_threads`, `email_messages` **File:** `src/lib/db/schema/operations.ts` — `reminders`, `google_calendar_tokens`, `google_calendar_cache`, `notifications`, `scheduled_reports`, `report_recipients` **File:** `src/lib/db/schema/system.ts` — `audit_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` ```typescript 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` ```typescript 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** ```bash 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** ```bash pnpm add better-auth @better-auth/redis ``` **File:** `src/lib/auth/index.ts` ```typescript 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` ```typescript 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` ```typescript 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` ```typescript 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 = ( req: NextRequest, ctx: AuthContext, params: Record, ) => Promise>; /** * Composable API route wrapper. * Usage: export const GET = withAuth(withPermission('clients', 'view', handler)) */ export function withAuth( handler: RouteHandler, ): ( req: NextRequest, context: { params: Promise> }, ) => Promise { 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 | 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, source: Record, ): Record { 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, source[key] as Record, ); } else { result[key] = source[key]; } } return result; } ``` **Step 16: Next.js page-level middleware** **File:** `src/middleware.ts` ```typescript 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: ```typescript 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 ```typescript 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** ```bash pnpm add ioredis ``` **File:** `src/lib/redis.ts` ```typescript 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` ```typescript 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 { 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 { 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** ```bash pnpm add pino pino-pretty ``` **File:** `src/lib/logger.ts` ```typescript 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` ```typescript 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 = { 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` ```typescript 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; newValue?: Record; metadata?: Record; ipAddress: string; userAgent: string; } export async function createAuditLog(params: AuditLogParams): Promise { 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, newRecord: Record, ): 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): Record | 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` ```typescript 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(fn: (tx: Database) => Promise): Promise { 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 { 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 { await db .update(table) .set({ archivedAt: null } as any) .where(and(eq(table.id, id), eq(table.portId, portId))); } ``` **Step 24: MinIO client** ```bash pnpm add minio ``` **File:** `src/lib/minio/index.ts` ```typescript 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 { 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 { 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` ```typescript 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 = {}; 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** ```bash 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: ```typescript // 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` ```typescript 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 | null = null; export function initSocketServer( httpServer: HTTPServer, ): Server { const pubClient = redis.duplicate(); const subClient = redis.duplicate(); io = new Server(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 { 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( room: string, event: E, ...args: Parameters ): void { if (!io) return; io.to(room).emit(event, ...args); } ``` **File:** `src/providers/socket-provider.tsx` ```typescript '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(null); export function SocketProvider({ children }: { children: ReactNode }) { const { data: session } = useSession(); const currentPortId = usePortStore((s) => s.currentPortId); const [socket, setSocket] = useState(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 ( {children} ); } export function useSocket() { return useContext(SocketContext); } ``` **Step 27: BullMQ setup** ```bash pnpm add bullmq ``` **File:** `src/lib/queue/index.ts` ```typescript 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(); 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` ```typescript 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 { 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.): ```typescript // 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** ```bash 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: ```bash 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` ```typescript 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 ( {children} ); } ``` **File:** `src/app/(dashboard)/layout.tsx` ```typescript 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 ( pr.port)} defaultPortId={portRoles[0]?.port.id}>
{children}
); } ``` **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** ```bash pnpm add zustand ``` **File:** `src/stores/ui-store.ts` ```typescript 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()( 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** ```bash pnpm add @tanstack/react-query @tanstack/react-query-devtools ``` **File:** `src/providers/query-provider.tsx` ```typescript '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 ( {children} {process.env.NODE_ENV === 'development' && } ); } ``` **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 `` + `` 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` ```typescript // 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. ```typescript 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: ```bash # 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 **85–95 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: ```ts 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`: ```ts 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.