Skip to content

Latest commit

 

History

History
817 lines (642 loc) · 37.3 KB

File metadata and controls

817 lines (642 loc) · 37.3 KB

CLAUDE.md — Full Stack System Starter

Read this file fully before touching any file. Read PROJECT_UNDERSTANDING.md for project-specific business context before any sprint.


What This Starter Is

A production-ready full-stack starter by Bob Mahmoud Bebars. It ships with a Fastify 5 backend and a React 19 frontend — fully wired together with JWT auth, RBAC, real-time Socket.IO, background job queues, file storage, email, and push notifications out of the box.

Designed for per-client ERP-style systems: one installation per client, no multi-tenancy, each client gets their own dedicated deployment.


Repository Structure

project-root/
├── backend/              # Fastify 5 + TypeScript + Prisma + MySQL
├── frontend/             # React 19 + Vite + Tailwind + shadcn/ui
├── .env                  # Single .env at root — read by both backend and build
├── .env.example
├── package.json          # Root script runner only (not a workspace)
├── docker-compose.yml    # Production: backend + frontend containers (cloud DB/Redis)
├── docker-compose.dev.yml# Local dev: MariaDB + Redis + Mailhog
├── Dockerfile.backend
├── Dockerfile.frontend
├── .github/workflows/
│   └── deploy.yml        # CI/CD: build images + deploy to VPS or AWS (manual trigger)
├── CLAUDE.md             # This file
├── PROJECT_UNDERSTANDING.md
├── PLAN.md
└── TASKS.md

Two independent apps. /backend and /frontend each have their own package.json and node_modules. There is no npm workspace, no Turborepo, no shared packages directory.

The root package.json exists only to run convenience scripts (npm run dev:backend, npm run install:all, etc.). It is not a workspace config and must never be treated as one.

The .env file lives at the project root. The backend reads it from ../../../.env relative to src/config/env.ts. Frontend env vars (VITE_*) are baked in at build time via Vite.


Backend Stack

Layer Technology Version Notes
Runtime Node.js 22+
Framework Fastify ^5.2.1 No Express, no NestJS, never
Language TypeScript ^5.7.0 strict: true, noUnusedLocals, noImplicitReturns
ORM Prisma ^7.6.0 MariaDB adapter. Postgres-portable schema.
DB adapter @prisma/adapter-mariadb ^7.6.0 Required for MariaDB/MySQL with Prisma 7
Database MySQL / MariaDB Cloud-managed in production (RDS, Railway, PlanetScale)
Cache / Queue Redis + BullMQ ^5.0.0 Redis via @fastify/redis. BullMQ not Bull.
Auth @fastify/jwt + @fastify/cookie ^9 / ^11 Access token (15min) + refresh rotation (7d)
Validation Zod ^3.23.8 Routes + env vars
Logging Pino bundled Via Fastify. Never console.log.
File uploads @fastify/multipart ^9.0.3 All I/O through storage.service.ts only
Image process sharp ^0.33.5 Resize + convert to WebP via lib/media.ts
Docs @fastify/swagger + swagger-ui ^9 / ^5 Dev/staging only, disabled in production
Real-time socket.io ^4.8.1 httpOnly cookie auth, user rooms, Redis socket registry
Static files @fastify/static ^8.1.0 Serves uploads under /cdn/ path
Rate limit @fastify/rate-limit ^10.2.2
Security @fastify/helmet ^13.0.1
CORS @fastify/cors ^11.0.0
Email nodemailer ^8.0.4 SMTP via env vars, disabled if MAIL_HOST empty
Notifications NTFY (push) Optional, via NTFY_URL + NTFY_CHANNEL env vars

Why BullMQ over Bull: Bull is in maintenance mode. BullMQ is the official successor with native TypeScript support. Never use the old bull package.

Why Prisma over Sequelize: Type-safe, better DX, cleaner migrations, straightforward MySQL-to-Postgres switch.


Frontend Stack

Layer Technology Version Notes
Framework React ^19.2.4
Build tool Vite ^8.0.1
Language TypeScript ~5.9.3
Styling Tailwind CSS ^4.2.2
Components shadcn/ui + radix-ui ^4.1.1
Charts Recharts ^3.8.0
HTTP client Axios ^1.14.0
Routing React Router ^7.13.2 react-router-dom
Real-time socket.io-client ^4.8.3 Auth via httpOnly cookies, no token in headers
QR display qrcode.react ^4.2.0 QR label generation, frontend-side
QR scan html5-qrcode ^2.3.8 Camera-based QR scanning
Barcodes jsbarcode ^3.12.3
PDF export jspdf ^4.2.1 Print-ready label and report generation
Excel export exceljs ^4.4.0
CSV parse papaparse ^5.5.3
Date utils date-fns ^4.1.0
Toasts Sonner ^2.0.7
Date picker react-day-picker ^9.14.0
Theme next-themes ^0.4.6 Light/dark mode
Validation Zod ^4.3.6 Note: backend uses Zod v3

Backend Module Pattern

Every feature module is self-contained under src/modules/{name}/:

modules/users/
├── users.routes.ts      # Route registration only — schema + preHandlers, no logic
├── users.controller.ts  # Thin handlers — call service, send reply
├── users.service.ts     # All business logic and DB queries
└── users.schema.ts      # Zod schemas: body, params, query, response types

Rules:

  • Controllers never import Prisma directly. All DB access goes through the service.
  • Services never call reply.send(). They return data or throw AppError.
  • Routes own schema and preHandlers only — no logic, no direct Prisma imports.
  • A {name}.types.ts file may be added for module-specific TypeScript types if needed.

Protecting a route:

fastify.post(
  "/items",
  {
    preHandler: [fastify.authenticate, fastify.isAllowed],
    config: { permission: "items.create" },
    schema: { body: CreateItemSchema },
  },
  controller.createItem,
);

Backend Folder Structure

backend/
├── src/
│   ├── config/
│   │   ├── env.ts               # Zod-validated env vars — server exits on missing vars
│   │   └── prisma.ts            # PrismaClient singleton
│   ├── plugins/
│   │   ├── authenticate.ts      # fastify.authenticate decorator
│   │   ├── rbac.ts              # fastify.isAllowed decorator
│   │   ├── socket.ts            # Socket.IO server — cookie auth, user rooms, Redis registry
│   │   └── cache.ts             # Redis cache helpers
│   ├── modules/                 # Feature modules (routes / controller / service / schema)
│   │   ├── auth/
│   │   ├── users/
│   │   ├── roles/
│   │   ├── permissions/
│   │   ├── settings/
│   │   ├── notifications/
│   │   ├── queue/
│   │   └── deploy/
│   ├── workers/
│   │   └── file-processor.worker.ts  # BullMQ worker — file-processing queue
│   ├── jobs/
│   │   └── file-processing.job.ts    # BullMQ queue definition + typed job data
│   ├── lib/
│   │   ├── errors.ts            # AppError class and factory helpers
│   │   ├── response.ts          # ok, paginated, created helpers
│   │   ├── pagination.ts        # Offset pagination helpers
│   │   ├── media.ts             # sharp image processing (resize to WebP)
│   │   ├── permissions-sync.ts  # Syncs constants/permissions.ts to DB on startup
│   │   ├── settings-sync.ts     # Syncs constants/settings.ts to DB on startup
│   │   └── version-sync.ts      # Upserts app_version from package.json into DB on startup
│   ├── services/
│   │   ├── storage.service.ts       # ONLY file that touches the filesystem or S3
│   │   ├── notification.service.ts  # notify() — DB write + email + Socket.IO emit
│   │   └── email.service.ts         # Nodemailer wrapper with HTML templates
│   ├── utils/
│   │   ├── passwords.ts         # bcrypt wrappers
│   │   └── random.ts            # UUID and token generators
│   ├── types/
│   │   ├── fastify.d.ts         # FastifyInstance augmentation
│   │   └── jwt.d.ts             # @fastify/jwt payload augmentation
│   ├── constants/
│   │   ├── permissions.ts       # Permission key registry (source of truth)
│   │   └── settings.ts          # Default settings definitions
│   ├── enums/
│   │   └── index.ts             # as const enums only — never TypeScript enum keyword
│   └── server.ts                # Fastify instance, plugin registration, boot
├── prisma/
│   ├── schema.prisma
│   ├── prisma.config.ts         # Prisma v7 config — datasource url lives here
│   ├── migrations/
│   └── seed.ts
├── package.json
├── tsconfig.json
└── (no .env here — lives at project root)

Pre-Built Modules

These modules ship with the starter and are fully implemented. Do not rebuild them.

Module Routes prefix What it does
auth /auth Login, logout, refresh, me, permissions endpoint
users /users CRUD + soft delete + avatar upload + password reset
roles /roles CRUD for roles
permissions /permissions Read + assign permissions to roles
settings /settings Read/update app settings, Redis flush endpoint
notifications /notifications In-app notification list, read/unread, count
queue /queue BullMQ job submission + status polling endpoint
deploy /admin/deploy GitHub webhook-triggered deploy — returns 503 if not configured

Frontend Architecture Rules

These rules apply to every frontend module without exception.

Directory Structure

src/
├── pages/
│   └── {Module}.tsx      # Route-level pages only — thin shells that compose components
├── components/
│   ├── {module}/         # Components used only within this module
│   │   ├── {Name}Table.tsx
│   │   ├── {Name}Row.tsx
│   │   ├── {Name}Dialog.tsx
│   │   └── {Name}Filters.tsx
│   ├── shared/           # Global app-wide reusable components
│   └── ui/               # shadcn/ui primitives — never modify directly
├── hooks/                # All hooks — used anywhere in the app
├── providers/            # React context providers (auth, theme)
├── routes/               # Route definitions
├── layout/               # App shell, sidebar, nav
├── constants/            # Frontend constants (env URLs, etc.)
├── types/                # TypeScript types
├── lib/                  # Utility functions (api.ts, cn utils)
├── data/                 # Static data
├── styles/               # Global CSS
└── socket-listen.tsx     # Top-level Socket.IO event listeners

Pages = route shells. They import and compose components. No dialog JSX, no sheet JSX, no inline form logic lives in a page file.

Components = all actual UI pieces. Each component has one clear responsibility.

Hooks = all data fetching and state logic. Shared freely across pages and components.

Page File Rules

A page file must only contain:

  • useDataApi call for the main list data
  • useFilterParams for filter/search state synced to URL
  • State that belongs to the page level (selected item, dialog open state)
  • Composition of imported components
  • PageHeader, TotalCounter, Card, loading/error/empty states

A page file must never contain:

  • Dialog or Sheet JSX defined inline
  • Form logic or form state
  • Direct API calls (use hooks or pass handlers to components)
  • Table row definitions inline (extract to a Row or Table component)

Component Responsibilities

Component type Responsibility
{Name}Table.tsx Table shell + column headers. Renders {Name}Row per item.
{Name}Row.tsx Single table row. Receives item as prop. Owns row-level actions.
{Name}Dialog.tsx Create/edit dialog. Owns form state. Calls API on submit.
{Name}Sheet.tsx Side panel for secondary detail. Self-contained.
{Name}Filters.tsx Filter controls rendered inside FilterPopover.

Data Fetching Pattern

Always use useDataApi for all data fetching. Never use raw axios.get in a component or page directly.

const queryParams = new URLSearchParams({
  page: filters.page,
  limit: filters.limit,
  ...(filters.search && { search: filters.search }),
  ...(filters.status !== "all" && { is_active: filters.status }),
});

const { results, loading, error, refetch, meta } = useDataApi<Item[]>(
  `items?${queryParams}`,
  authenticated,
);

Use useFilterParams for all filter state — never useState for filters. useFilterParams syncs to URL search params automatically (shareable links, survives refresh, resets page on filter change).

API Select / Searchable Select Pattern

Never fetch dropdown data with a hardcoded high limit (limit=200). All selects requiring API data must use SearchableSelect from components/shared/searchable-select.tsx.

<SearchableSelect
  path="users"
  labelKey="name"
  valueKey="id"
  searchKey="search"
  placeholder="Select user"
  value={selectedId}
  onChange={(val) => setSelectedId(val)}
/>

Shared Components — Always Use, Never Recreate

Component Usage
PageHeader Every page — title, left slot for TotalCounter, right slot for actions
TotalCounter Shows "Showing X of Y" — always in PageHeader left slot
FilterPopover Wraps all secondary filter controls — icon trigger, active count badge
RefreshButton Every list page — wired to refetch from useDataApi
StatusBadge Active/inactive display — never inline colored text
StatusButton Toggle active/inactive action
Pagination Every paginated list — receives page, setPage, totalPages, limit, setLimit
TableSkelaton Loading state for tables
GridSkelaton Loading state for grid layouts
PageSkelaton Full-page loading state
ErrorHandler Error state display
SectionLoader Loading state for sections within a page
SearchableSelect API-backed searchable dropdown — always use for remote-data selects
RangeDateSelector Date range picker
SortAction Sort control for sortable lists
JsonObjectEditor Editable JSON field for settings values

Filter Layout Pattern

[Search input — grows to fill space]  [FilterPopover]  [RefreshButton]

Search is always outside the popover — always visible. Secondary filters (dropdowns, toggles, date ranges) go inside FilterPopover.

Available Hooks

Hook Purpose
useDataApi All GET data fetching — returns results, loading, error, refetch, meta
useFilterParams URL-synced filter state for list pages
usePermissions Fetches current user's permission keys from API
useCanAccessAction Returns canAccess(key: string) — checks if user has a permission key
useSocket Subscribes to a Socket.IO event, handles connect/disconnect lifecycle
useListener Fetches current authenticated user from GET /auth
useFlushCache Calls PUT /settings/redis/flush — for settings/admin pages
useDragSort Drag-and-drop sort state for ordered lists
useMobile Returns boolean — true when viewport is mobile width

Error Handling

All backend errors go through AppError. Never throw raw new Error() in services.

// lib/errors.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: unknown,
  ) { ... }
}

export const notFound     = (resource: string)          => new AppError(404, `${resource}_NOT_FOUND`, ...)
export const unauthorized = (msg = 'Unauthorized')      => new AppError(401, 'UNAUTHORIZED', msg)
export const forbidden    = (msg = 'Forbidden')         => new AppError(403, 'FORBIDDEN', msg)
export const badRequest   = (msg: string, details?)     => new AppError(400, 'BAD_REQUEST', msg, details)
export const conflict     = (resource: string)          => new AppError(409, `${resource}_CONFLICT`, ...)

Fastify's global error handler formats all errors to:

{
  "success": false,
  "code": "ITEM_NOT_FOUND",
  "message": "item not found",
  "statusCode": 404
}

Zod validation errors, Fastify schema errors, and Prisma P2002 unique violations are caught and formatted. No raw Error objects or ORM internals ever reach the client.


Standardized Response Shape

Use helpers from lib/response.ts. The response key is results, not data.

reply.send(ok(item));
// { success: true, results: { ...item } }

reply.send(paginated(items, { page, limit, total }));
// { success: true, results: [...], meta: { page, limit, total, totalPages } }

reply.status(201).send(created(item));
// { success: true, results: { ...item } }

Storage Service (Critical)

All file I/O must go through src/services/storage.service.ts only. No route, controller, module service, or worker touches the filesystem, S3 SDK, or Cloudinary directly.

uploadFile(file: MultipartFile, subPath: string): Promise<string>  // returns driver-specific stored path
getFileUrl(storedPath: string): string                             // returns publicly accessible URL
deleteFile(storedPath: string): Promise<void>

Supported drivers (set via STORAGE_DRIVER in .env):

Driver Requires Notes
filesystem (default) nothing Files at STORAGE_PATH, served at APP_URL/cdn/
s3 npm install @aws-sdk/client-s3 @aws-sdk/lib-storage AWS S3, MinIO, R2, DO Spaces via AWS_ENDPOINT_URL
cloudinary npm install cloudinary Auto CDN + on-the-fly image transforms

To activate a non-default driver: set the env var, install the package, uncomment the implementation block in storage.service.ts. Nothing else in the codebase changes.

For image uploads requiring resize/convert use lib/media.ts::processAssetFile — it calls sharp to resize and convert to WebP before writing.

Files are served under /cdn/ by @fastify/static. Never under /public/ — that conflicts with the React SPA catch-all.


Auth System

Flow

  1. POST /auth/login — verify credentials, issue access token (15min) + refresh token (7d)
  2. Both tokens set as httpOnly; Secure; SameSite=Strict cookies
  3. POST /auth/refresh — verify refresh token, rotate both, revoke old
  4. POST /auth/logout — delete refresh token, clear both cookies

JWT Payload

// types/jwt.d.ts
declare module "@fastify/jwt" {
  interface FastifyJWT {
    payload: {
      sub: number;
      role: { id: number; slug: string; name: string };
      jti: string;
    };
    user: {
      sub: number;
      role: { id: number; slug: string; name: string };
      jti: string;
    };
  }
}

Cookie Config (Non-negotiable)

{
  httpOnly: true,                            // never comment this out
  secure: env.NODE_ENV === 'production',
  sameSite: 'strict',                        // always a string, never boolean true
  path: '/',
}

Account Security

  • 5 consecutive failed logins: is_active = false, tracked in Redis as login:attempts:<userId>
  • Login always returns "Invalid credentials" for wrong password and unknown user — no enumeration
  • Passwords hashed with bcrypt, 12 rounds

RBAC System

  • Role has many RolePermission
  • Permission has many RolePermission
  • User belongs to Role
  • Permission format: module.action — e.g. users.read, items.create
  • All permissions defined in constants/permissions.ts and synced to DB on startup
  • Super Admin bypass is always by role.slug === RoleSlug.SUPER_ADMIN, never by role.id

Email Service

All outbound email must go through src/services/email.service.ts only.

sendEmail(log, to, subject, html): Promise<void>  // never throws — failures are logged and swallowed

Supported drivers (set via MAIL_DRIVER in .env):

Driver Package Credentials needed
smtp (default) none MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASSWORD
ses none (SES SMTP endpoint) SES_SMTP_HOST, SES_SMTP_USER, SES_SMTP_PASSWORD
sendgrid none (SendGrid SMTP relay) SENDGRID_API_KEY

Both ses and sendgrid work via nodemailer's SMTP transport — no extra packages. Switch by changing MAIL_DRIVER and setting the relevant vars.

Email templates are TypeScript functions returning HTML strings. Always use baseTemplate(title, bodyHtml) as the outer wrapper. Built-in templates: notificationEmailTemplate, passwordResetTemplate, welcomeTemplate. Add new templates following the same pattern in email.service.ts.

Local dev: add Mailhog to docker-compose.yml — it catches all outgoing email at http://localhost:8025. Set MAIL_HOST=localhost, MAIL_PORT=1025.


Notification Service

notify(fastify, userId, title, message, type, options?)

notify() is not a global singleton — it requires fastify as its first argument. It always writes to the notifications table, sends email if configured, and emits via Socket.IO to user:{userId}. All steps are best-effort — failures are logged and swallowed, never thrown.

Optional extra channels via NotifyOptions:

Option Channel Requires
{ ntfy: true } NTFY push to admin channel NTFY_URL + NTFY_CHANNEL
{ sms: true, phone: '+1...' } Twilio SMS TWILIO_ACCOUNT_SID/AUTH_TOKEN/FROM_NUMBER
{ push: true, playerId: '...' } OneSignal mobile/web push ONESIGNAL_APP_ID + ONESIGNAL_API_KEY

Twilio and OneSignal use their REST APIs directly — no SDK package required.

Always pass fastify down from the plugin into any function that calls notify(). Never import and call notify without a fastify instance.


Startup Syncs

Three syncs run on every server boot before routes are available:

  1. syncPermissions — upserts all entries from constants/permissions.ts into the permissions table
  2. syncSettings — upserts all entries from constants/settings.ts into the settings table
  3. syncAppVersion — reads version from package.json and upserts it into the settings table

These are idempotent. Safe to run on every boot.


Prisma Rules

  • Singleton: One PrismaClient from config/prisma.ts. Never new PrismaClient() anywhere else.
  • Config: Prisma v7 uses prisma.config.ts for datasource URL — not the datasource block in schema.
  • Soft delete: Handle at service layer with manual deleted_at checks. Do not use soft-delete Prisma extensions — incompatible with the MariaDB adapter.
  • Password: Exclude password on every user query. Define a userSelect const and reuse it.
  • Transactions: Always prisma.$transaction(async (tx) => { ... }) for multi-step writes.
  • No raw queries unless absolutely unavoidable — add a comment explaining why.
  • Migrations: prisma migrate dev in dev. prisma migrate deploy in production. Never db push in production.
  • Enums: Use Prisma schema enums — provider-portable. Never raw MySQL ENUM DDL outside Prisma.
  • Postgres portability: No MySQL-specific types (TINYINT, MEDIUMTEXT, etc.). Use Boolean, String, Int, Decimal, DateTime.

TypeScript Rules

  • strict: true — non-negotiable
  • noUnusedLocals, noUnusedParameters, noImplicitReturns — all enabled
  • No any — use unknown and narrow
  • No @ts-ignore without a comment explaining the exact reason
  • All async functions have explicit return types
  • All Fastify handlers typed: FastifyRequest<{ Body: CreateItemBody }>
  • Use as const objects instead of the TypeScript enum keyword
// CORRECT
export const WorkOrderStatus = {
  PENDING: "PENDING",
  IN_PROGRESS: "IN_PROGRESS",
} as const;
export type WorkOrderStatus =
  (typeof WorkOrderStatus)[keyof typeof WorkOrderStatus];

// WRONG — never use this
enum WorkOrderStatus {
  PENDING = "PENDING",
}

Deployment Model (Docker)

Container platform (Railway / AWS ECS / Fly.io / Hostinger / any Docker host)
├── backend container    → Dockerfile.backend (node dist/server.js, port 5000)
│                           Runs prisma migrate deploy on startup
├── frontend container   → Dockerfile.frontend (Nginx serving React SPA, port 80)
└── named volume         → cdn/ (only when STORAGE_DRIVER=filesystem)

External cloud services (NOT containerized)
├── MySQL / MariaDB      → AWS RDS · PlanetScale · Railway · DigitalOcean DB
└── Redis                → Upstash · Redis Cloud · Railway Redis

Rules:

  • Database and Redis are always cloud-managed in production — never self-hosted alongside the app containers
  • Backend migration runs automatically at container startup via prisma migrate deploy in the CMD
  • Use prisma migrate deploy (never migrate dev) in production
  • Use npm install — never npm ci (cross-platform lock file incompatibility risk)
  • Frontend is served by Nginx inside Dockerfile.frontend — no separate Nginx host process needed
  • For STORAGE_DRIVER=filesystem: mount a persistent named volume at /app/cdn in docker-compose.yml
  • For STORAGE_DRIVER=s3 or cloudinary: remove the cdn volume entirely

Local development:

npm run dev:backend   # Fastify with hot reload (tsx + nodemon)
npm run dev:frontend  # Vite dev server

docker compose -f docker-compose.dev.yml up -d
# → MariaDB on :3306
# → Redis on :6379
# → Mailhog on :1025 (SMTP) + :8025 (Web UI)

Environment Variables

All vars validated with Zod in config/env.ts. Server exits immediately on any missing required var. The .env file lives at the project root.

# App
NODE_ENV=development
APP_TITLE=App_NAME
APP_URL=http://localhost:5000
HOST=localhost
PORT=5000
APP_TIMEZONE=UTC

# Auth — all three required, all different, min 32 chars each
JWT_SECRET=
JWT_EXPIRES_IN=15m
REFRESH_SECRET=
REFRESH_EXPIRES_IN=7d
COOKIE_SECRET=

# Database
DATABASE_URL=mysql://user:pass@localhost:3306/db_name
DATABASE_HOST=127.0.0.1
DATABASE_USER=root
DATABASE_PASSWORD=
DATABASE_NAME=db_name
DATABASE_PORT=3306

# Redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=

# CORS (comma-separated)
ALLOWED_ORIGINS=http://localhost:5173

# Rate limiting
RATE_LIMIT_MAX=1000
RATE_LIMIT_WINDOW=1 minute

# Queue
QUEUE_CONCURRENCY=3

# Logging
LOG_LEVEL=info
PRETTY_LOGS=true

# Storage
STORAGE_DRIVER=filesystem
STORAGE_PATH=./cdn/
MAX_FILE_SIZE=52428800

# Frontend (baked in at build time via Vite)
VITE_APP_TITLE=App_NAME
VITE_APP_URL=http://localhost:5000/    # must end with trailing slash

# Admin seed user
ADMIN_NAME=System Admin
ADMIN_EMAIL=admin@local
ADMIN_PHONE=

# Email (Nodemailer) — leave empty to disable
MAIL_HOST=
MAIL_PORT=587
MAIL_USER=
MAIL_PASSWORD=
MAIL_FROM=

# Push notifications (NTFY) — optional
NTFY_CHANNEL=
NTFY_URL=https://ntfy.sh

# GitHub deploy hook — optional, returns 503 if absent
GITHUB_TOKEN=
GITHUB_REPO=

Logging Rules

  • Use fastify.log or request.log everywhere — never console.log
  • console.log anywhere in source code = fix immediately, do not commit
  • Levels: error for exceptions, warn for recoverable issues, info for lifecycle events, debug for dev
  • Never log: passwords, tokens, full request bodies, PII

Code Style

  • Single quotes in TypeScript
  • Trailing commas in objects and arrays
  • 2-space indentation
  • Named exports preferred — default exports only for Fastify plugin files (required by fastify-plugin)
  • File naming: kebab-case.ts
  • No barrel index.ts re-exports — import from the file directly
  • No em-dashes in comments or strings — use : or ,
  • Comments explain WHY not WHAT

What Never Goes in This Project

  • No Express, no NestJS, no Hapi
  • No Next.js
  • No pnpm or yarn — npm only
  • No any type
  • No console.log in any committed code
  • No hardcoded secrets or credentials
  • No direct Prisma access from controllers
  • No business logic in route files
  • No old bull package — BullMQ only
  • No TypeScript enum keyword — as const only
  • No sameSite: true — always a string ('strict')
  • No httpOnly ever set to false or commented out
  • No multi-tenancy patterns — single-tenant per deployment
  • No role_id === 1 for super-admin checks — use role.slug
  • No direct fs or S3 imports outside storage.service.ts
  • No raw SQL unless unavoidable and documented
  • No npm ci — use npm install (cross-platform lock file risk)
  • No uploaded files served under /public/ — use /cdn/ path only
  • No notify() called without a fastify instance
  • No limit=200 or similar in dropdown API calls — use SearchableSelect

Key Decisions

Decision Reason
Fastify 5 over Express/NestJS Performance, TypeScript-first, schema validation built-in
Prisma over Sequelize Type-safe, better DX, easier MySQL-to-Postgres switch
BullMQ over Bull Bull in maintenance mode, BullMQ has native TypeScript support
JWT + httpOnly cookies XSS protection — tokens never accessible to JavaScript
Refresh token rotation with Redis Stateless JWT + server-side revocation capability
Role slug for permission checks ID hardcode breaks if seeder order changes
Filesystem storage with S3-ready interface Speed now, zero-rewrite switch later via storage.service.ts
Single .env at project root One source of truth for backend runtime and frontend build
Zod for env validation Server exits on missing vars — no silent undefined secrets
Startup permission/settings sync Code is source of truth, DB is auto-aligned — no manual SQL inserts
sharp for image processing Server-side resize + WebP conversion before storage
Soft-delete at service layer, no extension prisma-extension-soft-delete incompatible with MariaDB adapter
Docker-only deployment, no PM2 Portable across any container platform, no host-level process manager
Cloud-managed DB and Redis in production Eliminates ops burden, provides HA/backups/scaling out of the box

Known Anti-Patterns — Never Repeat

  1. httpOnly commented out on auth cookie — XSS vulnerability
  2. sameSite: true (invalid value) — malformed Set-Cookie header
  3. Socket middleware missing else { next(error) } — unauthenticated connections hang forever
  4. JWT verify in socket middleware without try/catch — unhandled rejection crash risk
  5. JWT_SECRET not in required env check — undefined secret is a critical security hole
  6. Password not excluded explicitly on every user query — define userSelect and reuse it
  7. Super Admin checked by role_id === 1 — breaks if seeder order changes
  8. console.log in error handler — bypasses structured logging in prod
  9. Raw Error objects in reply.send() — leaks ORM internals to API consumers
  10. ALLOWED_ORIGINS including literal null — unintentional iframe origin bypass
  11. No refresh token rotation — stateless JWT alone is insufficient
  12. Direct fs.writeFile in a route handler — bypasses storage abstraction, blocks S3 migration
  13. npm ci on Linux with a Windows-generated lock file — platform path separator mismatch
  14. Serving uploaded files under /public/ — conflicts with React SPA catch-all in Nginx inside Dockerfile.frontend
  15. Using soft-delete Prisma extension with MariaDB adapter — causes adapter incompatibility
  16. Calling notify() without passing fastifyfastify.log and fastify.io are required
  17. Using limit=200 in dropdown fetches — conflicts with backend max limits, does not scale

Session Handoff Protocol

When starting a new Claude Code session on any project built on this starter:

  1. Read CLAUDE.md (this file) fully
  2. Read PROJECT_UNDERSTANDING.md — business rules, domain, terminology for this project
  3. Read PLAN.md — find current phase and last completed sprint
  4. Check TASKS.md — blockers and open questions for current sprint
  5. Check prisma/schema.prisma for current DB state
  6. Run npm install from /backend and /frontend if node_modules is stale — never npm ci
  7. Never assume context from a previous session — always re-read all four files first