Files
pn-new-crm/competing-plans/blessed/L0-FOUNDATION.md

2908 lines
91 KiB
Markdown
Raw Permalink Normal View History

# L0 — Foundation (Competing Plan — Claude Code)
**Duration:** Days 69 (4 days)
**Parallelism:** None — sequential foundation; everything depends on this
**References:** `07-DATABASE-SCHEMA.md`, `10-AUTH-AND-PERMISSIONS.md`, `11-REALTIME-AND-BACKGROUND-JOBS.md`, `14-TECHNICAL-DECISIONS.md`, `15-DESIGN-TOKENS.md`, `SECURITY-GUIDELINES.md`
---
## 1. Baseline Critique
### What the baseline gets right
- **Step ordering is sound.** Scaffold → Docker → DB → Auth → Infra → Layout → Security is the correct dependency chain.
- **Drizzle schema grouping** (1 file per domain) is practical and matches the project scale.
- **Docker multi-stage build** with standalone output is correct for production.
- **Nginx security headers and rate limiting zones** are thorough and match `SECURITY-GUIDELINES.md`.
- **Acceptance criteria** are concrete and testable.
### What's missing or wrong
1. **Route structure is wrong.** The baseline uses `(crm)/` as the route group. The locked file organization in `PROMPT-CLAUDE-CODE.md` specifies `(dashboard)/[portSlug]/` with a dynamic port slug segment. This is a fundamental routing decision — every page URL, every link, every breadcrumb depends on it. Getting this wrong means rework across every layer.
2. **Session storage contradicts `14-TECHNICAL-DECISIONS.md`.** The baseline stores sessions in PostgreSQL. The locked tech decisions say `better-auth/plugins/redis` for session persistence. Redis sessions are faster and reduce DB load for the most frequent operation in the system (session validation on every request).
3. **BullMQ queue names don't match the spec.** The baseline invents names like `notification-processing`, `email-sync`, `email-send`. The spec in `11-REALTIME-AND-BACKGROUND-JOBS.md` defines exactly 10 queues: `email`, `documents`, `notifications`, `import`, `export`, `reports`, `webhooks`, `maintenance`, `ai`, `bulk`. Use the spec names.
4. **Role count mismatch.** The baseline seeds 4 roles (`super_admin`, `director`, `sales`, `readonly`). The spec in `10-AUTH-AND-PERMISSIONS.md` Section 2.4 defines 5 system roles: `super_admin`, `director`, `sales_manager`, `sales_agent`, `viewer`. All 5 must be seeded.
5. **Missing environment variables.** The `.env.example` omits `CSRF_SECRET`, `EMAIL_CREDENTIAL_KEY`, `DOCUMENSO_WEBHOOK_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `PUBLIC_SITE_URL` — all required by `SECURITY-GUIDELINES.md`.
6. **Socket.io + Next.js integration is hand-waved.** "Create Socket.io server alongside Next.js custom server" is not a plan. Next.js standalone mode runs `node server.js` with no extension point for WebSocket servers. This needs a custom `server.ts` entry point that boots both Next.js and Socket.io, or a separate worker process. The approach must be specified.
7. **No Drizzle relations defined.** Schemas without `relations()` declarations mean no relational query builder (`db.query.*.findFirst({ with: ... })`). The auth middleware pseudocode in the baseline itself uses `with: { role: true }` — this requires relation definitions.
8. **No application-level rate limiting.** The baseline only has nginx rate limiting. `SECURITY-GUIDELINES.md` Section 6.1 requires Redis sliding window rate limiting at the application layer with `X-RateLimit-*` response headers.
9. **No `pgcrypto` extension.** Security guidelines require AES-256-GCM encryption for email credentials and pgcrypto for Google Calendar tokens. The database init must enable this extension.
10. **No env validation.** No Zod schema validating that all required environment variables are present and well-formed at startup. The app should fail fast with a clear error, not crash mid-request when `process.env.DATABASE_URL` is undefined.
11. **No custom server entry point.** BullMQ workers need a long-running process. Next.js standalone `server.js` doesn't run background workers. Need either a custom server wrapper or a separate worker process in Docker Compose.
12. **Missing `lint-staged`.** Pre-commit hook runs ESLint on the entire project. With `lint-staged` + `husky`, only staged files are linted — dramatically faster as the project grows.
13. **No health check endpoints.** Docker Compose health checks need an HTTP endpoint. The baseline's postgres and redis have health checks, but `crm-app` has none.
14. **`next.config.ts` incomplete.** Needs `serverExternalPackages` for `pino`, `bullmq`, `ioredis`, `minio`, `postgres` — these packages use native Node.js APIs that Next.js's bundler will break if it tries to bundle them.
### What I'd change structurally
- **Split the custom server concern early.** Define a `server.ts` that boots Next.js + Socket.io + BullMQ workers together in development, and use separate Docker Compose services for `crm-app` (Next.js) and `crm-worker` (BullMQ) in production.
- **Define Zod env schema as the very first file.** Every other module imports validated config from it.
- **Port slug in URLs from Day 1.** `/(dashboard)/[portSlug]/clients` not `/(crm)/clients`. This avoids a painful routing migration later.
- **Add a `lib/api/helpers.ts` pattern** for API route handlers — a composable middleware chain (`withAuth → withPort → withPermission → handler`) rather than ad-hoc checks in each route.
---
## 2. Implementation Plan
### Day 1 — Scaffold + Docker + Database Schema
#### Morning: Project Init (2 hours)
**Step 1: Create Next.js project**
```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<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`
```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<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`
```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<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`
```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<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`
```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<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**
```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<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`
```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<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`
```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<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**
```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<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`
```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<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**
```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<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`
```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<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**
```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<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`
```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<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.):
```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 (
<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`
```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 (
<QueryProvider>
<PortProvider ports={portRoles.map(pr => pr.port)} defaultPortId={portRoles[0]?.port.id}>
<SocketProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar ports={portRoles} />
<div className="flex-1 flex flex-col overflow-hidden">
<Topbar />
<main className="flex-1 overflow-y-auto bg-background p-6">
{children}
</main>
</div>
</div>
</SocketProvider>
</PortProvider>
</QueryProvider>
);
}
```
**File:** `src/components/layout/sidebar.tsx`
- Dark navy background (`bg-[#1e2844]`)
- Logo area: "PN" mark + "Port Nimara" + "Marina CRM"
- Navigation sections per `13-UI-PAGE-MAP.md`:
- **Main:** Dashboard, Clients, Interests, Berths
- **Financial:** Expenses, Invoices
- **Operations:** Files, Email, Reminders
- **Admin** (expandable, permission-gated): Users, Roles, Ports, Audit, Settings, etc.
- Active state: `border-l-2 border-brand bg-sidebar-active text-white`
- Hover state: `bg-[#171f35]`
- Icons: Lucide React (`LayoutDashboard`, `Users`, `Bookmark`, `Anchor`, `Receipt`, `FileText`, `FolderOpen`, `Mail`, `Bell`, `Settings`, `Shield`)
- Collapsible: persisted via Zustand `sidebarCollapsed`
- Mobile: Sheet/drawer triggered by hamburger
- User footer: avatar + name + role badge
- shadcn components: `Sheet`, `ScrollArea`, `Tooltip`, `Badge`, `Avatar`, `Separator`
**File:** `src/components/layout/topbar.tsx`
- Page title (derived from route params)
- Search box: `Button` with `⌘K` label → placeholder for Command palette (implemented in L3)
- Notification bell: `Button` with `Badge` unread count dot → placeholder panel
- "+ New" dropdown: `DropdownMenu` with context-aware options (New Client, New Interest, New Expense)
- Port switcher (only if 2+ ports): `Select` dropdown with port names
- User menu: `DropdownMenu` with Profile, Dark Mode toggle, Scratchpad, Logout
- shadcn components: `Button`, `DropdownMenu`, `Badge`, `Avatar`, `Separator`
**File:** `src/components/layout/port-switcher.tsx`
- Hidden when user has only 1 port
- `Select` dropdown showing port name
- On change: updates Zustand `currentPortId` → invalidates all TanStack Query caches → Socket.io reconnects to new port room
**File:** `src/components/layout/breadcrumbs.tsx`
- Auto-generated from `usePathname()` route segments
- Maps `[portSlug]` to port name
- Clickable segments using shadcn `Breadcrumb` component
**Step 31: Zustand UI store**
```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<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**
```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 (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
}
```
**Step 33: Base UI patterns**
**File:** `src/components/shared/page-header.tsx` — consistent page header with title, description, action buttons
**File:** `src/components/shared/empty-state.tsx` — "no data" pattern with icon, title, description, optional CTA
**File:** `src/components/shared/loading-skeleton.tsx` — reusable skeleton patterns (table, card, form)
**File:** `src/components/shared/confirmation-dialog.tsx` — destructive action confirmation using shadcn `AlertDialog`
**Step 34: Placeholder pages**
Create minimal placeholder pages for every route. Each renders `<PageHeader>` + `<EmptyState>` with "Coming in Layer X" message and correct breadcrumbs.
Pages under `src/app/(dashboard)/[portSlug]/`:
- `page.tsx` (dashboard)
- `clients/page.tsx`
- `interests/page.tsx`
- `berths/page.tsx`
- `expenses/page.tsx`
- `invoices/page.tsx`
- `documents/page.tsx`
- `email/page.tsx`
- `reminders/page.tsx`
- `reports/page.tsx`
- `settings/profile/page.tsx`
- `settings/notifications/page.tsx`
- `settings/calendar/page.tsx`
- `admin/page.tsx`
- `admin/users/page.tsx`
- `admin/roles/page.tsx`
- `admin/ports/page.tsx`
- `admin/audit/page.tsx`
- `admin/settings/page.tsx`
- `admin/webhooks/page.tsx`
- `admin/reports/page.tsx`
- `admin/templates/page.tsx`
- `admin/forms/page.tsx`
- `admin/tags/page.tsx`
- `admin/import/page.tsx`
- `admin/monitoring/page.tsx`
- `admin/backup/page.tsx`
- `admin/custom-fields/page.tsx`
- `admin/onboarding/page.tsx`
#### Afternoon: Security Baseline + Nginx + Verification (3 hours)
**Step 35: Nginx configuration**
**File:** `nginx/nginx.conf`
Full configuration matching `SECURITY-GUIDELINES.md` Section 6:
- TLS 1.3 only
- All security headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP, Permissions-Policy)
- Rate limiting zones: `auth` (5r/m), `general` (30r/s), `api` (60r/s), `upload` (10r/m)
- Proxy to `crm-app:3000`
- WebSocket upgrade for `/socket.io/`
- CORS for `/api/public/` (allow `portnimara.com` only)
- HTTP → HTTPS redirect
- Request body size limits (1MB JSON, 50MB uploads)
**Step 36: Constants file**
**File:** `src/lib/constants.ts`
```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 **8595 files**:
- Config files: ~10 (tsconfig, eslint, prettier, drizzle, next.config, docker, nginx, husky, lint-staged, .env.example)
- Schema files: 12 (10 domain + relations + index)
- Auth: 4 (server, client, permissions, api catch-all)
- Infrastructure: 8 (redis, rate-limit, logger, errors, audit, minio, env, constants)
- Socket.io: 4 (server, events, rooms, provider)
- BullMQ: 13 (index, scheduler, 10 worker stubs, types)
- Layout components: 5 (sidebar, topbar, port-switcher, breadcrumbs, dashboard layout)
- Shared components: 4 (page-header, empty-state, loading-skeleton, confirmation-dialog)
- Providers: 3 (query, socket, port)
- Stores: 1 (ui-store)
- Pages: ~32 (3 auth + 29 placeholder dashboard pages + not-found)
- Server: 1 (custom server.ts)
- Seed: 1
- API routes: 2 (auth catch-all, health)
---
## Codex Addenda — Merged from Competing Plan Review
The following items are cherry-picked from the Codex competing plan and should be incorporated during implementation. They represent architectural patterns and corrections that complement the Claude Code base plan.
### 1. Execution Spine Framing
Frame L0 not as "setup work" but as building the **execution spine** that all subsequent layers hang from. The reusable contracts produced here — `defineRoute`, `RequestContext`, `requirePermission`, `auditLog`, `buildStorageKey`, queue registry, and socket emitter — are the multiplier for 250+ endpoints. Pages stay minimal until those contracts exist.
### 2. Table Count Correction
The locked schema defines **51 application tables** plus Better Auth-managed auth tables. Plans referencing "49 tables" should be corrected. The coding agent will drift if the count is wrong.
### 3. Session Storage Resolution
Standardize on **PostgreSQL as the Better Auth session source of truth**. Redis is for cache, rate limits, queues, and Socket.io only. This resolves the contradiction across foundation docs.
### 4. Health Response Schema
Add a typed health check response schema:
```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.