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>
2908 lines
91 KiB
Markdown
2908 lines
91 KiB
Markdown
# L0 — Foundation (Competing Plan — Claude Code)
|
||
|
||
**Duration:** Days 6–9 (4 days)
|
||
**Parallelism:** None — sequential foundation; everything depends on this
|
||
**References:** `07-DATABASE-SCHEMA.md`, `10-AUTH-AND-PERMISSIONS.md`, `11-REALTIME-AND-BACKGROUND-JOBS.md`, `14-TECHNICAL-DECISIONS.md`, `15-DESIGN-TOKENS.md`, `SECURITY-GUIDELINES.md`
|
||
|
||
---
|
||
|
||
## 1. Baseline Critique
|
||
|
||
### What the baseline gets right
|
||
|
||
- **Step ordering is sound.** Scaffold → Docker → DB → Auth → Infra → Layout → Security is the correct dependency chain.
|
||
- **Drizzle schema grouping** (1 file per domain) is practical and matches the project scale.
|
||
- **Docker multi-stage build** with standalone output is correct for production.
|
||
- **Nginx security headers and rate limiting zones** are thorough and match `SECURITY-GUIDELINES.md`.
|
||
- **Acceptance criteria** are concrete and testable.
|
||
|
||
### What's missing or wrong
|
||
|
||
1. **Route structure is wrong.** The baseline uses `(crm)/` as the route group. The locked file organization in `PROMPT-CLAUDE-CODE.md` specifies `(dashboard)/[portSlug]/` with a dynamic port slug segment. This is a fundamental routing decision — every page URL, every link, every breadcrumb depends on it. Getting this wrong means rework across every layer.
|
||
|
||
2. **Session storage contradicts `14-TECHNICAL-DECISIONS.md`.** The baseline stores sessions in PostgreSQL. The locked tech decisions say `better-auth/plugins/redis` for session persistence. Redis sessions are faster and reduce DB load for the most frequent operation in the system (session validation on every request).
|
||
|
||
3. **BullMQ queue names don't match the spec.** The baseline invents names like `notification-processing`, `email-sync`, `email-send`. The spec in `11-REALTIME-AND-BACKGROUND-JOBS.md` defines exactly 10 queues: `email`, `documents`, `notifications`, `import`, `export`, `reports`, `webhooks`, `maintenance`, `ai`, `bulk`. Use the spec names.
|
||
|
||
4. **Role count mismatch.** The baseline seeds 4 roles (`super_admin`, `director`, `sales`, `readonly`). The spec in `10-AUTH-AND-PERMISSIONS.md` Section 2.4 defines 5 system roles: `super_admin`, `director`, `sales_manager`, `sales_agent`, `viewer`. All 5 must be seeded.
|
||
|
||
5. **Missing environment variables.** The `.env.example` omits `CSRF_SECRET`, `EMAIL_CREDENTIAL_KEY`, `DOCUMENSO_WEBHOOK_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `PUBLIC_SITE_URL` — all required by `SECURITY-GUIDELINES.md`.
|
||
|
||
6. **Socket.io + Next.js integration is hand-waved.** "Create Socket.io server alongside Next.js custom server" is not a plan. Next.js standalone mode runs `node server.js` with no extension point for WebSocket servers. This needs a custom `server.ts` entry point that boots both Next.js and Socket.io, or a separate worker process. The approach must be specified.
|
||
|
||
7. **No Drizzle relations defined.** Schemas without `relations()` declarations mean no relational query builder (`db.query.*.findFirst({ with: ... })`). The auth middleware pseudocode in the baseline itself uses `with: { role: true }` — this requires relation definitions.
|
||
|
||
8. **No application-level rate limiting.** The baseline only has nginx rate limiting. `SECURITY-GUIDELINES.md` Section 6.1 requires Redis sliding window rate limiting at the application layer with `X-RateLimit-*` response headers.
|
||
|
||
9. **No `pgcrypto` extension.** Security guidelines require AES-256-GCM encryption for email credentials and pgcrypto for Google Calendar tokens. The database init must enable this extension.
|
||
|
||
10. **No env validation.** No Zod schema validating that all required environment variables are present and well-formed at startup. The app should fail fast with a clear error, not crash mid-request when `process.env.DATABASE_URL` is undefined.
|
||
|
||
11. **No custom server entry point.** BullMQ workers need a long-running process. Next.js standalone `server.js` doesn't run background workers. Need either a custom server wrapper or a separate worker process in Docker Compose.
|
||
|
||
12. **Missing `lint-staged`.** Pre-commit hook runs ESLint on the entire project. With `lint-staged` + `husky`, only staged files are linted — dramatically faster as the project grows.
|
||
|
||
13. **No health check endpoints.** Docker Compose health checks need an HTTP endpoint. The baseline's postgres and redis have health checks, but `crm-app` has none.
|
||
|
||
14. **`next.config.ts` incomplete.** Needs `serverExternalPackages` for `pino`, `bullmq`, `ioredis`, `minio`, `postgres` — these packages use native Node.js APIs that Next.js's bundler will break if it tries to bundle them.
|
||
|
||
### What I'd change structurally
|
||
|
||
- **Split the custom server concern early.** Define a `server.ts` that boots Next.js + Socket.io + BullMQ workers together in development, and use separate Docker Compose services for `crm-app` (Next.js) and `crm-worker` (BullMQ) in production.
|
||
- **Define Zod env schema as the very first file.** Every other module imports validated config from it.
|
||
- **Port slug in URLs from Day 1.** `/(dashboard)/[portSlug]/clients` not `/(crm)/clients`. This avoids a painful routing migration later.
|
||
- **Add a `lib/api/helpers.ts` pattern** for API route handlers — a composable middleware chain (`withAuth → withPort → withPermission → handler`) rather than ad-hoc checks in each route.
|
||
|
||
---
|
||
|
||
## 2. Implementation Plan
|
||
|
||
### Day 1 — Scaffold + Docker + Database Schema
|
||
|
||
#### Morning: Project Init (2 hours)
|
||
|
||
**Step 1: Create Next.js project**
|
||
|
||
```bash
|
||
pnpm create next-app@latest port-nimara-crm \
|
||
--typescript --tailwind --eslint --app \
|
||
--src-dir --import-alias "@/*" \
|
||
--use-pnpm
|
||
```
|
||
|
||
**Step 2: Environment validation**
|
||
|
||
**File:** `src/lib/env.ts`
|
||
|
||
```typescript
|
||
import { z } from 'zod';
|
||
|
||
const envSchema = z.object({
|
||
// Database
|
||
DATABASE_URL: z.string().url().startsWith('postgresql://'),
|
||
|
||
// Redis
|
||
REDIS_URL: z.string().url().startsWith('redis://'),
|
||
|
||
// Auth
|
||
BETTER_AUTH_SECRET: z.string().min(32),
|
||
BETTER_AUTH_URL: z.string().url(),
|
||
CSRF_SECRET: z.string().min(32),
|
||
|
||
// MinIO
|
||
MINIO_ENDPOINT: z.string().min(1),
|
||
MINIO_PORT: z.coerce.number().int().positive(),
|
||
MINIO_ACCESS_KEY: z.string().min(1),
|
||
MINIO_SECRET_KEY: z.string().min(1),
|
||
MINIO_BUCKET: z.string().min(1),
|
||
MINIO_USE_SSL: z.enum(['true', 'false']).transform((v) => v === 'true'),
|
||
|
||
// Documenso
|
||
DOCUMENSO_API_URL: z.string().url(),
|
||
DOCUMENSO_API_KEY: z.string().min(1),
|
||
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
|
||
|
||
// Email
|
||
SMTP_HOST: z.string().min(1),
|
||
SMTP_PORT: z.coerce.number().int().positive(),
|
||
|
||
// Encryption
|
||
EMAIL_CREDENTIAL_KEY: z
|
||
.string()
|
||
.length(64)
|
||
.regex(/^[0-9a-f]+$/i, 'Must be 64-char hex'),
|
||
|
||
// Google OAuth
|
||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||
|
||
// OpenAI
|
||
OPENAI_API_KEY: z.string().optional(),
|
||
|
||
// App
|
||
APP_URL: z.string().url(),
|
||
PUBLIC_SITE_URL: z.string().url(),
|
||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||
});
|
||
|
||
export type Env = z.infer<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 **85–95 files**:
|
||
|
||
- Config files: ~10 (tsconfig, eslint, prettier, drizzle, next.config, docker, nginx, husky, lint-staged, .env.example)
|
||
- Schema files: 12 (10 domain + relations + index)
|
||
- Auth: 4 (server, client, permissions, api catch-all)
|
||
- Infrastructure: 8 (redis, rate-limit, logger, errors, audit, minio, env, constants)
|
||
- Socket.io: 4 (server, events, rooms, provider)
|
||
- BullMQ: 13 (index, scheduler, 10 worker stubs, types)
|
||
- Layout components: 5 (sidebar, topbar, port-switcher, breadcrumbs, dashboard layout)
|
||
- Shared components: 4 (page-header, empty-state, loading-skeleton, confirmation-dialog)
|
||
- Providers: 3 (query, socket, port)
|
||
- Stores: 1 (ui-store)
|
||
- Pages: ~32 (3 auth + 29 placeholder dashboard pages + not-found)
|
||
- Server: 1 (custom server.ts)
|
||
- Seed: 1
|
||
- API routes: 2 (auth catch-all, health)
|
||
|
||
---
|
||
|
||
## Codex Addenda — Merged from Competing Plan Review
|
||
|
||
The following items are cherry-picked from the Codex competing plan and should be incorporated during implementation. They represent architectural patterns and corrections that complement the Claude Code base plan.
|
||
|
||
### 1. Execution Spine Framing
|
||
|
||
Frame L0 not as "setup work" but as building the **execution spine** that all subsequent layers hang from. The reusable contracts produced here — `defineRoute`, `RequestContext`, `requirePermission`, `auditLog`, `buildStorageKey`, queue registry, and socket emitter — are the multiplier for 250+ endpoints. Pages stay minimal until those contracts exist.
|
||
|
||
### 2. Table Count Correction
|
||
|
||
The locked schema defines **51 application tables** plus Better Auth-managed auth tables. Plans referencing "49 tables" should be corrected. The coding agent will drift if the count is wrong.
|
||
|
||
### 3. Session Storage Resolution
|
||
|
||
Standardize on **PostgreSQL as the Better Auth session source of truth**. Redis is for cache, rate limits, queues, and Socket.io only. This resolves the contradiction across foundation docs.
|
||
|
||
### 4. Health Response Schema
|
||
|
||
Add a typed health check response schema:
|
||
|
||
```ts
|
||
export const healthResponseSchema = z.object({
|
||
status: z.enum(['healthy', 'degraded', 'down']),
|
||
services: z.array(
|
||
z.object({
|
||
name: z.string(),
|
||
status: z.enum(['healthy', 'degraded', 'down']),
|
||
latencyMs: z.number().nullable(),
|
||
detail: z.string().optional(),
|
||
}),
|
||
),
|
||
});
|
||
```
|
||
|
||
### 5. Storage Key Builder Signature
|
||
|
||
Adopt Codex's explicit entity enum in `buildStorageKey`:
|
||
|
||
```ts
|
||
export function buildStorageKey(input: {
|
||
portSlug: string;
|
||
entity: 'clients' | 'expenses' | 'invoices' | 'documents' | 'general';
|
||
entityId: string;
|
||
extension: string;
|
||
}): string;
|
||
```
|
||
|
||
Storage keys use UUID filenames only: `{portSlug}/{entity}/{entityId}/{uuid}.{ext}`.
|
||
|
||
### 6. Graceful Degradation Edge Cases
|
||
|
||
- **Redis unavailable on boot:** App still starts, but queues, rate limiting, and sockets are marked degraded in health check.
|
||
- **MinIO unavailable on boot:** File-dependent routes are disabled via startup status, not silent failures.
|
||
- **Duplicate socket connections:** Beyond 10 per user are rejected.
|
||
- **Presigned URLs:** Expire after 15 minutes and are never cached client-side in persisted state.
|
||
- **User has no assigned ports:** Redirect to a blocked access page, not the dashboard.
|
||
- **One active port only:** Port switcher component never mounts.
|
||
|
||
### 7. Security: Queue Job Payloads
|
||
|
||
Never put secrets or raw credentials into BullMQ job payloads — enqueue record IDs only.
|
||
|
||
### 8. Docker Acceptance Criteria
|
||
|
||
`docker compose up` must start `crm-app`, `postgres`, `redis`, and `nginx` without manual patching. This should be verified as part of L0 acceptance.
|