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>
91 KiB
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
-
Route structure is wrong. The baseline uses
(crm)/as the route group. The locked file organization inPROMPT-CLAUDE-CODE.mdspecifies(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. -
Session storage contradicts
14-TECHNICAL-DECISIONS.md. The baseline stores sessions in PostgreSQL. The locked tech decisions saybetter-auth/plugins/redisfor session persistence. Redis sessions are faster and reduce DB load for the most frequent operation in the system (session validation on every request). -
BullMQ queue names don't match the spec. The baseline invents names like
notification-processing,email-sync,email-send. The spec in11-REALTIME-AND-BACKGROUND-JOBS.mddefines exactly 10 queues:email,documents,notifications,import,export,reports,webhooks,maintenance,ai,bulk. Use the spec names. -
Role count mismatch. The baseline seeds 4 roles (
super_admin,director,sales,readonly). The spec in10-AUTH-AND-PERMISSIONS.mdSection 2.4 defines 5 system roles:super_admin,director,sales_manager,sales_agent,viewer. All 5 must be seeded. -
Missing environment variables. The
.env.exampleomitsCSRF_SECRET,EMAIL_CREDENTIAL_KEY,DOCUMENSO_WEBHOOK_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,PUBLIC_SITE_URL— all required bySECURITY-GUIDELINES.md. -
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.jswith no extension point for WebSocket servers. This needs a customserver.tsentry point that boots both Next.js and Socket.io, or a separate worker process. The approach must be specified. -
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 useswith: { role: true }— this requires relation definitions. -
No application-level rate limiting. The baseline only has nginx rate limiting.
SECURITY-GUIDELINES.mdSection 6.1 requires Redis sliding window rate limiting at the application layer withX-RateLimit-*response headers. -
No
pgcryptoextension. Security guidelines require AES-256-GCM encryption for email credentials and pgcrypto for Google Calendar tokens. The database init must enable this extension. -
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_URLis undefined. -
No custom server entry point. BullMQ workers need a long-running process. Next.js standalone
server.jsdoesn't run background workers. Need either a custom server wrapper or a separate worker process in Docker Compose. -
Missing
lint-staged. Pre-commit hook runs ESLint on the entire project. Withlint-staged+husky, only staged files are linted — dramatically faster as the project grows. -
No health check endpoints. Docker Compose health checks need an HTTP endpoint. The baseline's postgres and redis have health checks, but
crm-apphas none. -
next.config.tsincomplete. NeedsserverExternalPackagesforpino,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.tsthat boots Next.js + Socket.io + BullMQ workers together in development, and use separate Docker Compose services forcrm-app(Next.js) andcrm-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]/clientsnot/(crm)/clients. This avoids a painful routing migration later. - Add a
lib/api/helpers.tspattern 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.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
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:
- Default port:
{ name: 'Port Nimara', slug: 'port-nimara', defaultCurrency: 'USD', timezone: 'America/Anguilla' } - Five system roles matching
10-AUTH-AND-PERMISSIONS.mdSection 2.4:super_admin— all permissionstruedirector— all operational permissionstrue,admin.manage_users: true,admin.view_audit_log: true, system adminfalsesales_manager— full sales access,reminders.view_all: true,reminders.assign_others: truesales_agent— standard sales (view/create/edit clients/interests, own reminders only)viewer— allviewpermissionstrue, everything elsefalse
- Super admin user:
{ email: 'matt@portnimara.com', name: 'Matt', isSuperAdmin: true }— password set via email flow - Assign Matt to Port Nimara with
super_adminrole
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+Labelcomponents - 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:
Buttonwith⌘Klabel → placeholder for Command palette (implemented in L3) - Notification bell:
ButtonwithBadgeunread count dot → placeholder panel - "+ New" dropdown:
DropdownMenuwith context-aware options (New Client, New Interest, New Expense) - Port switcher (only if 2+ ports):
Selectdropdown with port names - User menu:
DropdownMenuwith 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
Selectdropdown 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
Breadcrumbcomponent
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.tsxinterests/page.tsxberths/page.tsxexpenses/page.tsxinvoices/page.tsxdocuments/page.tsxemail/page.tsxreminders/page.tsxreports/page.tsxsettings/profile/page.tsxsettings/notifications/page.tsxsettings/calendar/page.tsxadmin/page.tsxadmin/users/page.tsxadmin/roles/page.tsxadmin/ports/page.tsxadmin/audit/page.tsxadmin/settings/page.tsxadmin/webhooks/page.tsxadmin/reports/page.tsxadmin/templates/page.tsxadmin/forms/page.tsxadmin/tags/page.tsxadmin/import/page.tsxadmin/monitoring/page.tsxadmin/backup/page.tsxadmin/custom-fields/page.tsxadmin/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/(allowportnimara.comonly) - 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,#131a2cdark 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 viamotion-safe:animate-in
4. Acceptance Criteria
docker compose -f docker-compose.yml -f docker-compose.dev.yml upstarts postgres, redis, and crm-app successfully- All 49 database tables exist with correct schemas, constraints, indexes, and
pgcryptoextension - Seed data inserted: 1 port, 5 system roles, 1 super admin user
- Login page renders at
/loginwith Port Nimara branding (dark navy, PN logo) - Can log in with super admin credentials → redirected to
/(dashboard)/port-nimara/ - Session persists across page refreshes (httpOnly cookie, Redis-backed)
- Protected routes redirect to
/loginwhen not authenticated - API routes return 401 JSON when not authenticated
- Sidebar renders all navigation sections with correct icons
- All ~29 placeholder pages load with correct breadcrumbs and "Coming in Layer X" empty states
- Port switcher hidden (single port mode) — visible only with 2+ ports
- Socket.io connects on login, auto-joins
port:{portId}anduser:{userId}rooms - All 10 BullMQ queues initialized (visible in server logs)
- All 13 recurring jobs registered (visible in server logs)
- MinIO bucket exists and accessible (
/api/healthreportsminio: ok) - Health endpoint at
/api/healthreturns{ status: "healthy", checks: { postgres: "ok", redis: "ok", minio: "ok" } } - Structured logging active (pino JSON output in production, pretty-printed in dev)
- Nginx serves over HTTPS with all security headers (HSTS, CSP, X-Frame-Options, etc.)
- Application-level rate limiter functional (Redis sliding window)
- ESLint passes with zero errors, TypeScript strict mode passes with zero errors
- No secrets in source code —
.env.examplehas placeholders only, pre-commit hook catches accidental commits - Audit log middleware functional — login event written to
audit_logstable - Zod env validation fails fast on missing/invalid environment variables
- Dark mode toggle in user menu switches theme (CSS variables swap)
- 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:
AppErrorhierarchy, 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
anytypes, 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.mdpresent 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:
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.