Read this file fully before touching any file. Read PROJECT_UNDERSTANDING.md for project-specific business context before any sprint.
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.
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.
| 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 | |
| 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.
| 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 |
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 throwAppError. - Routes own schema and preHandlers only — no logic, no direct Prisma imports.
- A
{name}.types.tsfile 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/
├── 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)
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 |
These rules apply to every frontend module without exception.
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.
A page file must only contain:
useDataApicall for the main list datauseFilterParamsfor 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 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. |
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).
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)}
/>| 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 |
[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.
| 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 |
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.
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 } }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.
POST /auth/login— verify credentials, issue access token (15min) + refresh token (7d)- Both tokens set as
httpOnly; Secure; SameSite=Strictcookies POST /auth/refresh— verify refresh token, rotate both, revoke oldPOST /auth/logout— delete refresh token, clear both cookies
// 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;
};
}
}{
httpOnly: true, // never comment this out
secure: env.NODE_ENV === 'production',
sameSite: 'strict', // always a string, never boolean true
path: '/',
}- 5 consecutive failed logins:
is_active = false, tracked in Redis aslogin:attempts:<userId> - Login always returns
"Invalid credentials"for wrong password and unknown user — no enumeration - Passwords hashed with bcrypt, 12 rounds
Rolehas manyRolePermissionPermissionhas manyRolePermissionUserbelongs toRole- Permission format:
module.action— e.g.users.read,items.create - All permissions defined in
constants/permissions.tsand synced to DB on startup - Super Admin bypass is always by
role.slug === RoleSlug.SUPER_ADMIN, never byrole.id
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 swallowedSupported 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.
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.
Three syncs run on every server boot before routes are available:
syncPermissions— upserts all entries fromconstants/permissions.tsinto thepermissionstablesyncSettings— upserts all entries fromconstants/settings.tsinto thesettingstablesyncAppVersion— readsversionfrompackage.jsonand upserts it into thesettingstable
These are idempotent. Safe to run on every boot.
- Singleton: One
PrismaClientfromconfig/prisma.ts. Nevernew PrismaClient()anywhere else. - Config: Prisma v7 uses
prisma.config.tsfor datasource URL — not thedatasourceblock in schema. - Soft delete: Handle at service layer with manual
deleted_atchecks. Do not use soft-delete Prisma extensions — incompatible with the MariaDB adapter. - Password: Exclude password on every user query. Define a
userSelectconst 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 devin dev.prisma migrate deployin production. Neverdb pushin production. - Enums: Use Prisma schema enums — provider-portable. Never raw MySQL ENUM DDL outside Prisma.
- Postgres portability: No MySQL-specific types (
TINYINT,MEDIUMTEXT, etc.). UseBoolean,String,Int,Decimal,DateTime.
strict: true— non-negotiablenoUnusedLocals,noUnusedParameters,noImplicitReturns— all enabled- No
any— useunknownand narrow - No
@ts-ignorewithout a comment explaining the exact reason - All async functions have explicit return types
- All Fastify handlers typed:
FastifyRequest<{ Body: CreateItemBody }> - Use
as constobjects instead of the TypeScriptenumkeyword
// 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",
}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 deployin theCMD - Use
prisma migrate deploy(nevermigrate dev) in production - Use
npm install— nevernpm 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/cdnindocker-compose.yml - For
STORAGE_DRIVER=s3orcloudinary: 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)
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=- Use
fastify.logorrequest.logeverywhere — neverconsole.log console.loganywhere in source code = fix immediately, do not commit- Levels:
errorfor exceptions,warnfor recoverable issues,infofor lifecycle events,debugfor dev - Never log: passwords, tokens, full request bodies, PII
- 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.tsre-exports — import from the file directly - No em-dashes in comments or strings — use
:or, - Comments explain WHY not WHAT
- No Express, no NestJS, no Hapi
- No Next.js
- No pnpm or yarn — npm only
- No
anytype - No
console.login any committed code - No hardcoded secrets or credentials
- No direct Prisma access from controllers
- No business logic in route files
- No old
bullpackage — BullMQ only - No TypeScript
enumkeyword —as constonly - No
sameSite: true— always a string ('strict') - No
httpOnlyever set to false or commented out - No multi-tenancy patterns — single-tenant per deployment
- No
role_id === 1for super-admin checks — userole.slug - No direct
fsor S3 imports outsidestorage.service.ts - No raw SQL unless unavoidable and documented
- No
npm ci— usenpm install(cross-platform lock file risk) - No uploaded files served under
/public/— use/cdn/path only - No
notify()called without afastifyinstance - No
limit=200or similar in dropdown API calls — useSearchableSelect
| 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 |
httpOnlycommented out on auth cookie — XSS vulnerabilitysameSite: true(invalid value) — malformed Set-Cookie header- Socket middleware missing
else { next(error) }— unauthenticated connections hang forever - JWT verify in socket middleware without try/catch — unhandled rejection crash risk
JWT_SECRETnot in required env check — undefined secret is a critical security hole- Password not excluded explicitly on every user query — define
userSelectand reuse it - Super Admin checked by
role_id === 1— breaks if seeder order changes console.login error handler — bypasses structured logging in prod- Raw Error objects in
reply.send()— leaks ORM internals to API consumers ALLOWED_ORIGINSincluding literalnull— unintentional iframe origin bypass- No refresh token rotation — stateless JWT alone is insufficient
- Direct
fs.writeFilein a route handler — bypasses storage abstraction, blocks S3 migration npm cion Linux with a Windows-generated lock file — platform path separator mismatch- Serving uploaded files under
/public/— conflicts with React SPA catch-all in Nginx inside Dockerfile.frontend - Using soft-delete Prisma extension with MariaDB adapter — causes adapter incompatibility
- Calling
notify()without passingfastify—fastify.logandfastify.ioare required - Using
limit=200in dropdown fetches — conflicts with backend max limits, does not scale
When starting a new Claude Code session on any project built on this starter:
- Read
CLAUDE.md(this file) fully - Read
PROJECT_UNDERSTANDING.md— business rules, domain, terminology for this project - Read
PLAN.md— find current phase and last completed sprint - Check
TASKS.md— blockers and open questions for current sprint - Check
prisma/schema.prismafor current DB state - Run
npm installfrom/backendand/frontendif node_modules is stale — nevernpm ci - Never assume context from a previous session — always re-read all four files first