# Blenaxis Backend

Simple TypeScript backend: Node.js, Express 5, PostgreSQL, Prisma 7, and Joi. Authentication via Firebase; secured with Helmet, CORS, and rate limiting. Default port: **3000**.

## Local setup

Use a supported Node.js version (20.19+, 22.12+, or 24+; Node 24 LTS recommended) and a running local PostgreSQL instance.

1. Run `npm install`.
2. Create the database in pgAdmin or psql:

   ```sql
   CREATE DATABASE blenaxis;
   ```

   Your Postgres role needs `CREATE` on the `public` schema (Postgres 15+ no longer grants this by default). If migrations fail with a permission error, run as a superuser:

   ```sql
   GRANT CREATE ON SCHEMA public TO your_role;
   ```

3. Copy `.env.example` to `.env` and fill in your credentials:

   ```dotenv
   NODE_ENV=development
   PORT=3000
   DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@localhost:5432/blenaxis?schema=public"
   ```

   Replace `postgres` if your username differs. URL-encode special characters in the password (`@` becomes `%40`). `.env` is ignored by Git.

   `CORS_ORIGIN`, `TRUST_PROXY`, `RATE_LIMIT_WINDOW_MS`, and `RATE_LIMIT_MAX` are optional; see the comments in `.env.example` for defaults and when to change them. `FIREBASE_PROJECT_ID`, `FIREBASE_CLIENT_EMAIL`, and `FIREBASE_PRIVATE_KEY` are **required** — see [Authentication](#authentication).

4. Generate the client and create/apply the first migration:

   ```bash
   npm run db:generate
   npm run db:migrate -- --name init
   npm run db:seed
   ```

   `db:seed` creates the standard role list (see [Authentication](#authentication)) — `admin` and `user` (system roles, required for sign-up to work) plus the business roles from the Planning Module doc (`project-manager`, `site-engineer`, `qs`, `procurement`, `accounts`, `planning-manager`, `organization-admin`, `director`, `sales-executive`, `viewer`) — plus a representative permission set (`units.create/update/delete`) granted to `admin`, proving the `requirePermission()` mechanism (see [Roles & permissions](#roles--permissions)), plus the 3 subscription plans, 16-module catalog, and default platform settings row the SaaS Admin onboarding wizard needs (see [Organizations](#organizations-saas-admin--phase-1)).

5. Start: `npm run dev`. The server verifies the database connection before listening at `http://localhost:3000`.

Development migrations use a shadow database: your local PostgreSQL role needs permission to create databases, or a separate shadow database must be configured.

## TypeScript

The whole project is TypeScript (strict mode). `npm run dev` and `npm test` run `.ts` source directly via `tsx` (no build step needed locally). For production, `npm run build` compiles `src/` to `dist/` (via `tsconfig.build.json`) and `npm start` runs the compiled output. `npm run typecheck` type-checks `src/` and `test/` together without emitting anything — run it in CI.

## Database columns

Prisma model fields are camelCase (idiomatic TS), but every multi-word column is explicitly mapped to snake_case in the actual Postgres table via `@map("...")` in `prisma/schema.prisma` (e.g. `lastLoginAt` → `last_login_at`). Table names use `@@map` the same way (e.g. `RolePermission` → `role_permissions`). Prisma always quotes identifiers in the SQL it generates, so camelCase *would* otherwise be stored verbatim — the `@map`s here are a deliberate choice for snake_case columns, not something Postgres forces.

## Structure

```text
prisma/
  schema.prisma                 # Database models and relations
  migrations/                   # Created by db:migrate; commit these files
  seed.ts                       # Roles, permissions, SaaS Admin plans/modules/settings (npm run db:seed)
prisma.config.ts                # Prisma CLI configuration
tsconfig.base.json              # Shared compiler options
tsconfig.json                   # Type-checks src/ + test/ (noEmit; used by editors)
tsconfig.build.json             # Compiles src/ to dist/ for production
src/
  app.ts                        # Express setup: Helmet, CORS, rate limiting, /api/v1 routes
  server.ts                     # Connect DB, start server, graceful shutdown
  config/
    db.ts                       # Shared Prisma client and connection
    env.ts                      # Load and validate environment settings
    logger.ts                   # Winston logger (console + rotating log files)
    firebase.ts                 # Firebase Admin SDK init (service account)
    modules.ts                   # ModuleKey enum + catalog — cross-repo contract with the two frontends
  docs/
    openapiSpec.ts               # Hand-written OpenAPI spec served at /docs
  routes/
    index.ts                    # Register /api/v1 routes
    user.routes.ts              # Admin-only user directory + role assignment
    auth.routes.ts               # /signup, /login, /me, /logout
    role.routes.ts              # Admin-only role CRUD + permission assignment
    permission.routes.ts        # Admin-only permission CRUD
    unit.routes.ts               # Units master: open reads, admin-only writes
    costCode.routes.ts            # Cost codes master: open reads, admin-only writes
    material.routes.ts            # Materials master: open reads, admin-only writes
    equipment.routes.ts           # Equipment master: open reads, admin-only writes
    labour.routes.ts              # Labour master: open reads, admin-only writes
    organization.routes.ts        # SaaS Admin onboarding wizard, status/subscription/tenant-admin, admin-only
    contractor.routes.ts          # Contractors master: open reads, admin-only writes
    vendor.routes.ts              # Vendors master: open reads, admin-only writes
    project.routes.ts             # Project Information + team + documents + activity log
    wbs.routes.ts                  # WBS tree per project: open reads, admin-only writes
    plan.routes.ts                 # SaaS Admin subscription plans, admin-only
    module.routes.ts               # SaaS Admin module catalog (read-only), admin-only
    settings.routes.ts             # SaaS Admin platform settings, admin-only
    auditLog.routes.ts             # SaaS Admin platform-wide audit trail, admin-only
    invite.routes.ts               # Public: accept a tenant-admin invite
    dashboard.routes.ts            # SaaS Admin dashboard KPIs, admin-only
  controllers/
    health.controller.ts
    user.controller.ts
    auth.controller.ts
    role.controller.ts
    permission.controller.ts
    unit.controller.ts
    costCode.controller.ts
    material.controller.ts
    equipment.controller.ts
    labour.controller.ts
    organization.controller.ts
    contractor.controller.ts
    vendor.controller.ts
    project.controller.ts
    wbs.controller.ts
    plan.controller.ts
    module.controller.ts
    settings.controller.ts
    auditLog.controller.ts
    invite.controller.ts           # Mirrors auth.controller.ts's signup() for invite acceptance
    dashboard.controller.ts
  models/
    user.model.ts                # User directory query + role assignment
    auth.model.ts                 # Find/create local user by Firebase uid
    role.model.ts
    permission.model.ts
    unit.model.ts
    costCode.model.ts
    material.model.ts
    equipment.model.ts
    labour.model.ts
    organization.model.ts          # Onboarding, status/subscription/tenant-admin, usage computation
    contractor.model.ts
    vendor.model.ts
    project.model.ts               # Project + team members + activity log + documents
    wbs.model.ts                  # WbsListFilter (projectId / parentId incl. 'root')
    plan.model.ts                  # Flat DB columns <-> nested `limits` API shape
    module.model.ts
    settings.model.ts              # Single-row upsert (id always 1)
    invite.model.ts                # Token generation, INVITE_TTL_DAYS (7)
    auditLog.model.ts              # logAudit() writer + reader, used by every model above
    dashboard.model.ts             # Aggregates totals/seats/needsAttention/recentOrganizations
  validators/
    auth.validator.ts             # signup/login/refresh Joi schemas
    user.validator.ts             # Role-assignment Joi schema
    role.validator.ts            # Joi schemas
    permission.validator.ts
    unit.validator.ts
    costCode.validator.ts
    material.validator.ts
    equipment.validator.ts
    labour.validator.ts
    organization.validator.ts      # ORGANIZATION_STATUSES, SUSPENSION_REASONS, wizard/status/subscription/tenant-admin schemas
    contractor.validator.ts
    vendor.validator.ts
    project.validator.ts           # STATUSES (draft/submitted/active), team-member schema
    wbs.validator.ts
    plan.validator.ts
    settings.validator.ts
    invite.validator.ts
  middlewares/
    validate.ts                  # Reusable request body validation
    errorHandler.ts              # Shared API error handling
    requestId.ts                 # Assigns/echoes X-Request-Id, sets req.id
    requestLogger.ts             # Per-request access log (ip, status, timing, requestId)
    verifyFirebaseToken.ts       # Verifies the Firebase ID token, sets req.user
    authorize.ts                 # Restricts a route to given role(s)
    requirePermission.ts          # Restricts a route to a specific role_permissions entry (built, not yet applied — see Roles & permissions)
    methodNotAllowed.ts          # 405 (with Allow header) for a known path, wrong method
    docsAuth.ts                   # HTTP Basic Auth + session cookie, gates /docs
    upload.ts                     # multer disk storage (uploads/<subdir>/), 20MB limit, extension whitelist
  utils/
    response.ts                  # successResponse and errorResponse
    pagination.ts                 # parsePagination / paginatedResponse (page, limit)
    idCodec.ts                    # base64url encode/decode for ids used in URL paths
    firebaseIdentity.ts           # Identity Toolkit REST: password sign-in + refresh-token exchange
  types/
    express.d.ts                  # Adds req.user / req.id typing to Express's Request
test/
  api.test.ts                    # Users/health/error-contract tests, Prisma + Firebase stubbed
  auth.test.ts                   # Auth flow tests, Prisma + Firebase stubbed
  support/fakeFirebaseEnv.ts     # Throwaway PEM key firebase-admin needs to initialize in tests
.env.example
```

Prisma model definitions live in `prisma/schema.prisma`; `src/models` contains query functions. Request flow: route → validation → controller → model → database. Controller catches pass errors to the shared error middleware.

## API conventions

- **Versioned:** every route is under `/api/v1`. Bump to `/api/v2` for the next breaking change while `/api/v1` keeps working for existing clients.
- **Pagination:** list endpoints (`GET /api/v1/users`, `/roles`, `/permissions`) take `?page=1&limit=20` (`limit` capped at 100) and return `{ items, pagination: { page, limit, total, totalPages } }` as `data`. See `src/utils/pagination.ts`.
- **IDs in URLs are base64url-encoded** (e.g. `/api/v1/roles/Nw`, not `/api/v1/roles/7`) — see `src/utils/idCodec.ts`. Plain base64 can contain `/` and `+`, which would break a URL path segment; base64url swaps those for `-`/`_` and drops padding, so it's safe to use directly. No package needed — Node's `Buffer` supports it natively. A malformed id returns `400`, not a Prisma error. Encode with `Buffer.from(String(id)).toString('base64url')`; decode with `Buffer.from(encoded, 'base64url').toString()`. Applies to every resource, including `/api/v1/organizations/...` — the consuming frontend (blenaxis-super-admin) encodes the id itself before building the URL; the response body's own `id` field stays a plain integer, same as every other resource.
- **405 vs 404:** hitting a real path with the wrong method (e.g. `DELETE /api/v1/users`) returns `405` with an `Allow` header listing the valid methods, instead of a generic `404`. Only genuinely unknown paths return `404`.
- **Request correlation:** every response carries an `X-Request-Id` header (echoed back if the caller sent one, otherwise generated) and every log line for that request includes the same `requestId` — grep logs by it to trace one request end to end.
- **Controllers:** plain `async function` declarations with an explicit `try/catch { next(error) }`, exported together in one `export { ... }` at the bottom of the file — the health check is the one exception, since it turns a DB failure into a specific `503` response rather than forwarding to the generic error handler.

## Starter APIs

`/api/v1/users` is a directory of provisioned app users — admin-only, since it holds real account data. Real users are only ever created by signing in through Firebase (see [Authentication](#authentication)), not through this API; an admin can change a user's role here, e.g. to promote them past the `user` default.

| Method | URL | Auth required | Payload | Purpose |
| --- | --- | --- | --- | --- |
| GET | `/api/v1/health` | No | — | Database connectivity check (503 if unavailable) |
| GET | `/api/v1/users?page=&limit=` | Yes (admin) | — | Paginated user list, newest first |
| PATCH | `/api/v1/users/:id/role` | Yes (admin) | `{"roleId": 5}` | Change a user's role (`404` if the user doesn't exist, `400` if `roleId` doesn't) |
| PATCH | `/api/v1/users/:id/organization` | Yes (admin) | `{"organizationId": 5}` | Assign a user to an organization (`400` if `organizationId` doesn't exist) — see [Organizations](#organizations) |

```bash
curl http://localhost:3000/api/v1/health
curl http://localhost:3000/api/v1/users -H 'Authorization: Bearer <firebaseIdToken>'
curl -X PATCH http://localhost:3000/api/v1/users/<encodedId>/role \
  -H 'Authorization: Bearer <firebaseIdToken>' -H 'Content-Type: application/json' \
  -d '{"roleId":5}'
```

## Authentication

**Firebase Authentication** owns credentials — this backend never stores a password. `/signup` and `/login` are thin wrappers: they call Firebase server-side (Admin SDK to create the user, the Identity Toolkit REST API to verify a password) and issue Firebase's own tokens back to the client. Every subsequent request sends the current ID token as `Authorization: Bearer <idToken>`.

`verifyFirebaseToken` (`src/middlewares/verifyFirebaseToken.ts`) verifies that token with the Firebase Admin SDK, then finds — or, on a user's very first authenticated request, creates — the matching local `User` row (`firebaseUid`, cached `name`/`email`/`avatarUrl`/`emailVerified`, plus app-only fields: `role`, `isActive`, `lastLoginAt`). Firebase is the source of truth for credentials; this table is the source of truth for app data like roles.

Requires four env vars:

```dotenv
# Service account (Console → Project Settings → Service Accounts → Generate new private key)
FIREBASE_PROJECT_ID=
FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=
# Web API Key (Console → Project Settings → General) — a different credential,
# needed to verify a password at /login and to exchange a refresh token at /refresh
FIREBASE_WEB_API_KEY=
```

| Method | URL | Auth required | Payload | Purpose |
| --- | --- | --- | --- | --- |
| POST | `/api/v1/auth/signup` | No | `{"email","password"}` | `409` if the email is already registered; else creates the Firebase + local user |
| POST | `/api/v1/auth/login` | No | `{"email","password"}` | `404` if no such account; `401` if the password is wrong; `403 tenant_suspended` if the organization is suspended. Returns the session (below) |
| POST | `/api/v1/auth/refresh` | No | `{"refreshToken"}` | Exchanges a refresh token for a new session — `401` if it's invalid/expired/revoked. For a client with no Firebase SDK of its own (e.g. blenaxis-super-admin) to silently renew the 1-hour id token without asking for a password again. |
| GET | `/api/v1/auth/me` | Yes | — | Returns the current session (and, on first call, provisions the user) |
| PATCH | `/api/v1/auth/me` | Yes | `{"name","phone"?,"jobTitle"?}` | My profile: updates your own details (unknown keys such as `email` are rejected) and the Firebase display name; returns the session |
| POST | `/api/v1/auth/change-password` | Yes | `{"currentPassword","newPassword"}` | Verifies the current password with Firebase, sets the new one (8–72 chars with a number, different from the current), and signs in again since Firebase revokes existing sessions: returns `accessToken`/`refreshToken` (body and `X-Id-Token`/`X-Refresh-Token`). Wrong current password → `400` with `errors: [{ field: 'currentPassword' }]` (not `401`). Rate-limited like login |
| POST | `/api/v1/auth/logout` | Yes | — | Acknowledges logout (Firebase sessions are client-side; nothing to revoke server-side today) |

`signup`, `login`, and `refresh` return the session **both** ways — as `accessToken`/`refreshToken` fields in the JSON body (for a client with no Firebase SDK, which can only read the body, not headers) **and** as the original `X-Id-Token`/`X-Refresh-Token` response headers (kept for whatever already reads those):

```bash
curl -i -X POST http://localhost:3000/api/v1/auth/signup \
  -H 'Content-Type: application/json' \
  -d '{"email":"shubh@example.com","password":"Str0ngPass!"}'
# → body: { "data": { "user": {...}, "accessToken": "...", "refreshToken": "..." } }
# → headers: X-Id-Token / X-Refresh-Token (same values)

curl http://localhost:3000/api/v1/auth/me -H 'Authorization: Bearer <accessToken>'
curl -X POST http://localhost:3000/api/v1/auth/refresh -H 'Content-Type: application/json' -d '{"refreshToken":"<refreshToken>"}'
curl -X POST http://localhost:3000/api/v1/auth/logout -H 'Authorization: Bearer <accessToken>'
```

If a browser frontend needs to read `X-Id-Token`/`X-Refresh-Token` via `fetch`, they're already listed in `exposedHeaders` in `app.ts`'s CORS config — without that, browsers hide custom response headers from JS even when the request itself succeeds.

New sign-ups get the `user` role by default (auto-created if missing). To promote any other account to `admin` manually:

```sql
UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'admin') WHERE email = 'you@example.com';
```

To restrict any route to specific roles: `router.get('/admin-only', verifyFirebaseToken, authorize('admin'), handler)`. `/api/v1/users`, `/api/v1/roles`, `/api/v1/permissions`, and every [SaaS Admin](#organizations-saas-admin--phase-1) route all do this already.

### Bootstrapping the super admin

`npm run db:seed` creates the **one** platform-owner account that blenaxis-super-admin logs in as — see `seedSuperAdmin()` in `prisma/seed.ts`. **No env var is required** — it defaults to `superadmin@blenaxis.dev`, and if that account doesn't exist yet, generates a random password and logs it once (`Generated super admin password: ...` — save it from the terminal output, it's never shown again). `SUPER_ADMIN_EMAIL`/`SUPER_ADMIN_PASSWORD` are optional overrides only, for anyone who wants a fixed email/password instead.

It's **self-healing on every rerun**, not just "create once and never touch again":

- Looks up the Firebase user by email; if it doesn't exist (e.g. someone deleted it from the Firebase console), creates it fresh — with a **new** `uid` (and a newly generated password, logged the same way, unless `SUPER_ADMIN_PASSWORD` is set).
- Looks up the local `User` row by email; if it doesn't exist, creates it (`role: admin`). If it does exist but its `firebaseUid` is stale (the case above), its `roleId`, or its `isActive` don't match, updates them in place — never creates a second row for the same email.
- If everything already matches, does nothing and logs `Super admin already seeded` — an **existing** account's password is never touched or regenerated.

This is the account's *only* creation path — there's no signup form for it, on purpose (a platform owner shouldn't be self-registerable).

### Tenants and sessions

Each tenant is an `organizations` row (`module_keys` = its subscribed platform modules, `is_active = false` = suspended by BlenAxis). A user belongs to at most one organization (`users.organization_id`; null for platform staff and for sign-ups not linked yet). The access contract (module keys, permission codes, the 10 default roles with their data scope, module access and permissions) lives in `src/config/access.ts` and matches the tenant app's `config/permissions.ts` and mock roles.

`/auth/login` and `/auth/me` return the **session** as `data`:

```json
{
  "user": {
    "id": "Mw", "name": "Sneha Kulkarni", "email": "sneha@example.com",
    "roles": ["Planning Manager"],
    "permissions": ["planning.wbs.view", "planning.wbs.manage"],
    "modules": ["tenant_admin", "project_core", "planning"],
    "scope": "assigned",
    "role": "planning-manager", "avatarUrl": null, "emailVerified": true
  },
  "tenant": { "id": "MQ", "name": "Eastfield Developers", "slug": "eastfield",
              "moduleKeys": ["tenant_admin", "project_core", "planning"],
              "logoUrl": "https://…signed S3 URL…" /* or null; the tenant app shows it in the sidebar */,
              "admin": { "name": "Anita Rao", "email": "anita@example.com" } }
}
```

- `permissions` are **effective**: a role permission counts only while its module is subscribed by the organization and enabled for the role (Administration and Project Core are always on). `src/models/session.model.ts` resolves this the same way the tenant app explains it.
- `tenant` is `null` for a user not linked to an organization; the tenant app refuses to sign them in.
- A suspended organization gets `403 { "code": "tenant_suspended", "organizationName", "supportEmail" }` at login (only after a correct password), on `/auth/refresh` and on every authenticated request. `SUPPORT_EMAIL` (default `support@blenaxis.dev`) sets the contact.
- Firebase ID tokens expire after an hour: clients call `/auth/refresh` with the `X-Refresh-Token` from login.

`npm run db:seed` seeds the permissions, the default roles' access and a demo organization (`eastfield`). To sign in to the tenant app locally, sign up (or sign in once) through Firebase, then link the account:

```bash
npm run db:assign-user -- you@example.com eastfield organization-admin
```

## Tenant administration (`/api/v1/admin`)

The tenant app's Administration screens. Every route runs as the signed-in user's organization (`tenantAccess()` in `src/middlewares/tenantAccess.ts`): the organization comes from the account, never the request, and each route checks the tenant module **and** the user's effective permission, the same rule the app uses. Another organization's record is `404`, not `403`. Ids in URLs and bodies are base64url-encoded.

| Method & path | Permission | Notes |
| --- | --- | --- |
| `GET` · `PATCH /admin/organization` | `admin.organization.manage` | Profile: legal name, GSTIN (15 letters/digits), PAN, address, contact email, logo URL, time zone, currency, financial year start |
| `GET /admin/subscription` | Administration module | Plan, status (`active`/`trial`/`suspended`), modules, limits and usage (seats = active users) |
| `POST /admin/subscription/requests` | `admin.organization.manage` | `{ kind: seats\|modules\|other, message }` → `202`, stored in `subscription_requests` |
| `GET` · `PATCH /admin/setup` | `admin.organization.manage` | Setup checklist; `{ dismissed?, rolesReviewed? }` |
| `GET` · `POST /admin/departments`, `PATCH` · `DELETE /admin/departments/:id` | read: module; write: `admin.organization.manage` | Name unique per organization (`409`); delete is `409` while people are in it (archive instead) |
| `POST /admin/teams`, `PATCH` · `DELETE /admin/teams/:id` | `admin.organization.manage` | Deleting a team keeps its people in the department |
| `GET /admin/users?search&status&departmentId&roleId&page&limit` | `admin.users.view` | Paginated `{ items, pagination, statusCounts: { all, active, invited, disabled } }`; `search` matches name, email or job title; `statusCounts` apply every filter except `status` (for the status chips). Sorted by name |
| `GET /admin/users/options?status` · `GET /admin/users/:id` | `admin.users.view` | Options: everyone, unpaginated and light (`{ id, name, email, jobTitle?, status, roles }`), for pickers and duplicate checks |
| `PATCH /admin/users/:id` | `admin.users.manage` | Name, phone, job title, `roleIds` (several), department/team. Guard rails: only an Organization Admin grants/removes Organization Admin; you can't drop your own; at least one active Organization Admin stays |
| `PATCH /admin/users/:id/status` | `admin.users.manage` | `{ status: active\|disabled }`; can't deactivate yourself; reactivating past the seat limit is `422 { code: 'seat_limit' }`. Deactivating an invited person voids their link; reactivating them issues a new one |
| `POST /admin/invites` | `admin.users.manage` | `{ emails, roleIds, departmentId?, teamId?, projectIds }` → `201 { invited }`. One BlenAxis account per email (`409`), seat limit (`422 seat_limit`, seats = active + invited), only an Organization Admin invites an Organization Admin. Emails the invite (Mailgun) |
| `POST /admin/users/:id/invite` | `admin.users.manage` | Resend: new link, 7 more days (the old link stops working); `409` once they've joined |
| `POST /admin/users/:id/reset-password` | `admin.users.manage` | Firebase generates a one-time reset link, Mailgun emails it → `202`; active users only |
| `GET /admin/roles`, `GET /admin/roles/:id` | `admin.roles.view` | The 10 BlenAxis default roles (`isSystem: true`) plus the organization's custom roles, with permissions, modules, scope and user count |
| `POST /admin/roles`, `PATCH /admin/roles/:id` | `admin.roles.manage` | Custom roles only (default roles are `409`: clone them). `{ name, description, scope, permissions, modules, clonedFromId? }`; names unique per organization; only an Organization Admin grants Administration permissions |
| `DELETE /admin/roles/:id?reassignTo=` | `admin.roles.manage` | Custom roles only; when people hold it, `reassignTo` (another role) is required and they move to it |
| `PUT /admin/module-access` | `admin.roles.manage` | `{ [roleId]: ModuleKey[] }`. Only subscribed modules; Project Core always on; Organization Admin can't be restricted. Default roles get a per-organization override (`role_module_overrides`) |
| `GET /admin/projects?search&people&page&limit` | `project.projects.manage` (Project Core module) | Projects & sites setup, paginated `{ items: [{ id, code, name, location, sites: [{ id, name }], memberCount, createdAt }], pagination }`. `search` matches name, code, location or a site name; `people=with\|without` filters by whether anyone is assigned. An "assigned projects" role only gets the projects it's on |
| `POST /admin/projects` | `project.projects.manage` | `{ name, code, location?, sites: [{ name }] }` → 201. `code` is upper-cased, `^[A-Z0-9]+(-[A-Z0-9]+)*$`, unique platform-wide (409). 409 once the organization has `maxProjects` projects. An assigned-scope creator is added as a member |
| `PUT /admin/projects/:projectId` | `project.projects.manage` | Same body; `sites` is the full list: `{ id, name }` keeps (and renames) a site, `{ name }` adds one, a missing site is removed. 409 if anyone is limited to a removed site (they'd widen to every site) |
| `DELETE /admin/projects/:projectId` | `project.projects.manage` | Deletes the project with its sites and memberships; 409 once it has WBS items |
| `GET /admin/project-access` | `project.members.manage` (Project Core module) | Every project with its sites and members (org roles, project role, site limits) |
| `POST /admin/project-access/:projectId/members` | `project.members.manage` | `{ userIds, projectRole, siteIds }` (empty `siteIds` = every site); adding someone already on it updates them |
| `PATCH` · `DELETE /admin/project-access/:projectId/members/:userId` | `project.members.manage` | Change project role / sites, or remove |

**Outside `/admin`** (same tenant rules):

| Method & path | Permission | Notes |
| --- | --- | --- |
| `GET /projects` | `project.projects.view` | Every project for "all projects" roles; only the user's own projects when all their roles are scoped to assigned projects |
| `POST /access-requests` | signed in to an organization | `{ module?, path, message? }` → `202 { message }`; stored and emailed to the organization's Organization Admins (Mailgun template `blenaxis-access-request`) |

Projects and sites (`projects`, `sites`, `project_members`) exist for project access and scope; organizations add them in the tenant app's Projects & sites, and `npm run db:seed` adds two demo projects to the demo organization. Endpoints that confirm a request (`/admin/subscription/requests`, `/admin/users/:id/reset-password`, `/access-requests`) return `202` with `data: { message }`, which the apps show.

Tenant roles are held in `user_roles` (several per user, access is their union); `users.role_id` stays the platform role (`admin`/`user`) that `authorize()` checks. Custom roles are `roles` rows with `organization_id` set; their `name` is an internal key (`org-<id>-…`) and organizations see `label`.

**Not built yet** (Phase 3+, hidden in the Phase 1–2 release): creating/editing projects, `/tasks` and the WBS endpoints.

### Invites and email

Invited people are `users` rows without a Firebase account (`firebase_uid` null) holding a single-use `invite_token`,
valid 7 days from `invited_at`. The public endpoints behind the link:

| Method & path | Auth | Notes |
| --- | --- | --- |
| `GET /api/v1/invites/:token` | none | What the invite page shows: email, name, organization, who invited, expiry, roles. `410` if expired, used or unknown |
| `POST /api/v1/invites/:token/accept` | none (rate-limited like login) | `{ name, password }` (8+ characters with a number). Creates the Firebase account, signs in (tokens in the usual headers) and returns the session |

Signing in before accepting returns `401 "Accept your invite first…"`.

Email goes through **Mailgun templates** (`src/utils/mailer.ts`): the backend sends the template name and its variables.
The template sources, their variables and upload steps are in [`email-templates/`](email-templates/README.md). Settings:

```dotenv
MAILGUN_API_KEY=            # empty = don't send; in development the email and its link are logged instead
MAILGUN_DOMAIN=mg.example.com
MAILGUN_REGION=us           # or eu, matching the domain
MAIL_FROM=                  # default "BlenAxis <no-reply@MAILGUN_DOMAIN>"
TENANT_APP_URL=http://localhost:5173   # links in emails point here
MAILGUN_TEMPLATE_INVITE=blenaxis-invite
MAILGUN_TEMPLATE_PASSWORD_RESET=blenaxis-password-reset
```

### DB-backed tests

`test/tenantAdmin.test.ts` runs the admin API against a real Postgres database, **wiping it** on every run. It only runs when `TEST_DATABASE_URL` is set (it skips otherwise):

```dotenv
# .env: a separate, disposable database, never your DATABASE_URL
TEST_DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@localhost:5432/blenaxis_test?schema=public"
```

```bash
createdb blenaxis_test        # or CREATE DATABASE blenaxis_test; in psql
npm run test:db               # migrates the test database, then runs the DB tests
```

## API documentation (Swagger)

`GET /docs` serves Swagger UI, built from a hand-written OpenAPI spec at `src/docs/openapiSpec.ts` — **not** auto-generated from every route, so it only lists endpoints that have deliberately been added there (currently just `/auth/signup`, `/login`, `/refresh`, `/logout`, grouped under the "Auth" tag). Add a path there when a new endpoint is ready to document; nothing shows up automatically.

It's gated separately from the app's own Firebase auth — plain HTTP Basic Auth (`DOCS_USERNAME` / `DOCS_PASSWORD`), since it's protecting internal documentation, not an app user account. The browser's native login prompt appears on first visit; after that, a short-lived cookie (`DOCS_SESSION_MINUTES`, default 10) avoids re-prompting on every request — once it expires, the browser has to re-authenticate.

```dotenv
DOCS_USERNAME=
DOCS_PASSWORD=
DOCS_SESSION_MINUTES=10
```

## Roles & permissions

Admin-only CRUD, same `verifyFirebaseToken` + `authorize('admin')` pattern. `:id` is base64url-encoded (see [API conventions](#api-conventions)); list endpoints are paginated.

| Method | URL | Purpose |
| --- | --- | --- |
| GET/POST | `/api/v1/roles?page=&limit=` | List (paginated) / create roles |
| GET/PATCH/DELETE | `/api/v1/roles/:id` | Read / update / delete a role |
| PUT | `/api/v1/roles/:id/permissions` | Replace a role's permission set (body: `{ "permissionIds": [1,2] }`) |
| GET/POST | `/api/v1/permissions?page=&limit=` | List (paginated) / create permissions (dot-namespaced names, e.g. `users.create`) |
| DELETE | `/api/v1/permissions/:id` | Delete a permission |

**Two ways to gate a route**, both usable after `verifyFirebaseToken`:
- `authorize('admin', ...)` — checks the user's **role name**. Used everywhere today.
- `requirePermission('units.create')` — checks whether the user's role actually has that **specific permission** via `role_permissions` (finer-grained). Built and tested (`src/middlewares/requirePermission.ts`, `roleHasPermission()` in `role.model.ts`), seeded with a representative set (`units.create/update/delete`, all granted to `admin`) — but **not yet applied to any existing route**, on purpose: an empty `role_permissions` table (before `npm run db:seed` runs) would make every `requirePermission` check fail, breaking admin access that currently works via `authorize('admin')`. Switch a route over once its permission is seeded and assigned.

## Master data — units, cost codes, materials, equipment, labour, contractors, vendors

Phase 3's independent master tables (see the architecture doc). Unlike roles/permissions, **reads are open to any authenticated user** (most roles need to pick one, e.g. on a BOQ line later) — only writes are admin-only. Same pattern across all five:

| Method | URL | Auth required | Purpose |
| --- | --- | --- | --- |
| GET | `/api/v1/units?page=&limit=` | Any authenticated user | List units (paginated) |
| GET | `/api/v1/units/:id` | Any authenticated user | Read one unit |
| POST | `/api/v1/units` | Yes (admin) | Create a unit (body: `{"name","symbol"}`, both unique) |
| PATCH | `/api/v1/units/:id` | Yes (admin) | Update a unit |
| DELETE | `/api/v1/units/:id` | Yes (admin) | Delete a unit |
| GET | `/api/v1/cost-codes?page=&limit=` | Any authenticated user | List cost codes (paginated) |
| GET | `/api/v1/cost-codes/:id` | Any authenticated user | Read one cost code |
| POST | `/api/v1/cost-codes` | Yes (admin) | Create (body: `{"code","name","category"?,"description"?}`, `code` unique) |
| PATCH | `/api/v1/cost-codes/:id` | Yes (admin) | Update a cost code |
| DELETE | `/api/v1/cost-codes/:id` | Yes (admin) | Delete a cost code |
| GET | `/api/v1/materials?page=&limit=` | Any authenticated user | List materials (paginated, each with its `unit`) |
| GET | `/api/v1/materials/:id` | Any authenticated user | Read one material |
| POST | `/api/v1/materials` | Yes (admin) | Create (body: `{"name","unitId","code"?,"category"?,"description"?}`) |
| PATCH | `/api/v1/materials/:id` | Yes (admin) | Update a material |
| DELETE | `/api/v1/materials/:id` | Yes (admin) | Delete a material |
| GET | `/api/v1/equipment?page=&limit=` | Any authenticated user | List equipment (paginated, each with its `unit`) |
| GET | `/api/v1/equipment/:id` | Any authenticated user | Read one equipment entry |
| POST | `/api/v1/equipment` | Yes (admin) | Create (body: `{"name","unitId","code"?,"category"?,"description"?}`) |
| PATCH | `/api/v1/equipment/:id` | Yes (admin) | Update an equipment entry |
| DELETE | `/api/v1/equipment/:id` | Yes (admin) | Delete an equipment entry |
| GET | `/api/v1/labours?page=&limit=` | Any authenticated user | List labour categories (paginated, each with its `unit`) |
| GET | `/api/v1/labours/:id` | Any authenticated user | Read one labour category |
| POST | `/api/v1/labours` | Yes (admin) | Create (body: `{"name","unitId","code"?,"category"?,"description"?}`) |
| PATCH | `/api/v1/labours/:id` | Yes (admin) | Update a labour category |
| DELETE | `/api/v1/labours/:id` | Yes (admin) | Delete a labour category |
| GET | `/api/v1/contractors?page=&limit=` | Any authenticated user | List contractors (paginated) |
| GET | `/api/v1/contractors/:id` | Any authenticated user | Read one contractor |
| POST | `/api/v1/contractors` | Yes (admin) | Create (body: `{"name","code"?,"category"?,"contactPerson"?,"phone"?,"email"?,"address"?,"description"?}`) |
| PATCH | `/api/v1/contractors/:id` | Yes (admin) | Update a contractor |
| DELETE | `/api/v1/contractors/:id` | Yes (admin) | Delete a contractor |
| GET | `/api/v1/vendors?page=&limit=` | Any authenticated user | List vendors (paginated) |
| GET | `/api/v1/vendors/:id` | Any authenticated user | Read one vendor |
| POST | `/api/v1/vendors` | Yes (admin) | Create (same shape as contractors) |
| PATCH | `/api/v1/vendors/:id` | Yes (admin) | Update a vendor |
| DELETE | `/api/v1/vendors/:id` | Yes (admin) | Delete a vendor |

`materials`, `equipment`, and `labours` all require `unitId` (a real `units.id`) — `unitId` is the rate/measurement basis (e.g. "kg" for a material, "Day" for equipment hire or a labour rate). An unknown `unitId` returns `400`, not a raw database error. `labours` is a rate-card of labour categories (e.g. "Mason", "Helper") for costing — not individual workers; a worker who needs to log in becomes a `users` row with a role, same as any other user.

`code` is entered by the admin, not auto-generated from `name` — it follows whatever accounting convention the org already uses. `category` is a plain string (same pattern as `permissions.module`), not a separate lookup table.

`contractors` and `vendors` are separate tables even though they're structurally identical — a contractor executes work, a vendor only supplies goods (Phases 8 and 9). This is just the company profile; prequalification, tenders, and work orders are later additions.

## Organizations (SaaS Admin — Phase 1)

The multi-tenancy anchor. Unlike every other master table above, **this one is admin-only end to end** (reads included) — same pattern as `roles`/`permissions`, not the open-read pattern. Fields and endpoints match blenaxis-super-admin's "Add organization" onboarding wizard and its documented API contract exactly — this is the wizard's real backend, not a stub.

**One deliberate, scoped deviation from this backend's usual conventions**, because blenaxis-super-admin is the sole consumer of these specific routes and expects it: `GET /organizations` and `GET /audit-logs` return a flat `{items, total, page, pageSize}` shape, not this backend's usual `{items, pagination: {page, limit, total, totalPages}}` (see [Shared response format](#shared-response-format)). `:id` follows the normal base64url convention like every other resource.

`Plan.id` is a cuid string (not this backend's usual autoincrement int) for the same reason — the super-admin app treats plan ids as opaque strings.

### Onboarding a tenant

| Method | URL | Purpose |
| --- | --- | --- |
| POST | `/api/v1/organizations` | The wizard's submit — body: `{"name","slug","legalName"?,"contactEmail","contactPhone"?,"country","city"?,"planId","moduleKeys": ModuleKey[],"limits": {"maxUsers","maxProjects","storageGb"},"tenantAdmin": {"name","email","phone"?},"activateNow": boolean}`. `slug` must be `^[a-z0-9]+(-[a-z0-9]+)*$` and unique (`409` if taken). Core modules (`tenant_admin`, `project_core`) are unioned into `moduleKeys` server-side even if the client omits them. `activateNow: true` → `status: "active"`, 30-day renewal; `false` → `status: "trial"`, renews after `defaultTrialDays` (from [Settings](#settings-audit-log)). Creates a 7-day tenant-admin invite (see below) and a `platform_audit_log` entry (`organization.created`). |
| GET | `/api/v1/organizations?search=&status=&planId=&page=&pageSize=` | List. `statusCounts` (counts per status, ignoring the `status` filter itself — for tab badges) is always included. |
| GET | `/api/v1/organizations/:id` | Full detail — `limits`, live-computed `usage` (`maxUsers`/`maxProjects` are real counts; `storageGb` is always `0`, not tracked yet), `tenantAdmin` (derived from the latest invite, else the first active Organization Admin; `null` if neither; while the invite is pending and unexpired it carries `inviteUrl` = `${TENANT_APP_URL}/invite/<token>` and `inviteExpiresAt`, for Super Admin's "Copy invite link"), `subscription`, and `suspension` (only present when `status: "suspended"`). |
| GET | `/api/v1/organizations/:id/users` | Read-only list of that org's users (support view) |
| PATCH | `/api/v1/organizations/:id` | Basic profile fields only (`name`/`legalName`/`contactEmail`/`contactPhone`/`country`/`city`/`description`) — plan/modules/limits go through `/subscription` below, which requires a `reason` |

### Status, subscription and the tenant admin

| Method | URL | Purpose |
| --- | --- | --- |
| PATCH | `/api/v1/organizations/:id/status` | `{"status":"suspended","reason":"payment_overdue"\|"contract_ended"\|"security"\|"other","note"?,"notifyAdmin":boolean}` or `{"status":"active"}`. Logs `organization.suspended`/`organization.activated`. |
| PATCH | `/api/v1/organizations/:id/subscription` | `{"planId","moduleKeys","limits","priceMonthly","reason"}` — `reason` is required (`400` without it), core modules stay included, logs `organization.subscription_updated` with a before/after diff. |
| PUT | `/api/v1/organizations/:id/tenant-admin` | `{"mode":"existing","userId"}` (reassigns an existing user to `organization-admin` on this org) or `{"mode":"invite","name","email"}` (revokes any pending invite, creates a new one) |
| POST | `/api/v1/organizations/:id/tenant-admin/invite` | Resend — rotates the token/expiry. `409` if the current invite was already accepted. The tenant app's `/invite/<token>` page accepts it: `GET/POST /api/v1/invites/:token` fall back to organization invites, and accepting creates the user with the Organization Admin tenant role. |

**No email provider is wired up yet** — every invite's link/token is logged (`logger.info('Tenant admin invite created', ...)`) instead of emailed. Wire a provider (SES/SendGrid/etc.) when ready; nothing else about the invite flow needs to change.

### Accepting an invite

Public routes (no `Authorization` header — the token itself is the credential), mounted at `/api/v1/invites`:

| Method | URL | Purpose |
| --- | --- | --- |
| GET | `/api/v1/invites/:token` | `{"email","name","organizationName","expiresAt"}` — `404` unknown, `409` already accepted/revoked, `410` expired |
| POST | `/api/v1/invites/:token/accept` | Body `{"password"}` — creates the Firebase user + local `User` row (`organizationId` + `organization-admin` role from the invite), same `X-Id-Token`/`X-Refresh-Token` header pattern as [signup](#authentication) |

### Plans & modules

| Method | URL | Auth | Purpose |
| --- | --- | --- | --- |
| GET/POST | `/api/v1/plans` | admin | List / create a plan (body: `{"code","name","description"?,"priceMonthly","includedModuleKeys","limits","archived"?}`) |
| GET/PATCH | `/api/v1/plans/:id` | admin | Read / update |
| GET | `/api/v1/modules` | admin | The seeded module catalog (16 entries — see `src/config/modules.ts`, the cross-repo `ModuleKey` contract shared with blenaxis-super-admin and blenaxis-frontend). Read-only; no write API. |

Seeded plans: `STARTER` (₹24,999/mo · 25 users · 3 projects · 50GB), `GROWTH` (₹74,999/mo · 100 · 15 · 250GB), `ENTERPRISE` (₹249,999/mo · 1000 · 100 · 2000GB, every module) — see `prisma/seed.ts`.

### Settings, audit log & dashboard

| Method | URL | Auth | Purpose |
| --- | --- | --- | --- |
| GET/PUT | `/api/v1/settings` | admin | Single-row platform config (`platformName`, `supportEmail`, `defaultTrialDays`, `defaultCurrency`, `allowSelfSignup`, `maintenanceMode`) |
| GET | `/api/v1/audit-logs?organizationId=&action=&sinceDays=&page=&pageSize=` | admin | Platform-wide admin action trail (org onboarded/suspended/subscription changed/tenant-admin changed, ...) — separate from the per-project `ProjectActivityLog` under [Projects & WBS](#projects--wbs) |
| GET | `/api/v1/dashboard/summary` | admin | Platform-wide KPIs for the Super Admin dashboard: `totals` (org/user/project counts, MRR), `seats` (used vs sold across every org), `organizationsByStatus`, `needsAttention` (past-due renewals, trials ending within 7 days, pending tenant-admin invites, orgs at ≥90% of their seat limit), and the 5 most recently onboarded `recentOrganizations` |

### `users`/`roles`/`projects` and `organizationId`

`users`, `roles`, and `projects` all have an optional `organizationId` (nullable FK to `organizations`):
- `PATCH /api/v1/users/:id/organization` (admin-only, body: `{"organizationId": 5}`) assigns a user to an organization. `req.user.organizationId` is available on every authenticated request from here (set by `verifyFirebaseToken`).
- `roles.organizationId` is null for the seeded global roles (`admin`, `user`, ...) — a role only gets one set if an organization later defines its own custom role.
- `projects.organizationId` exists so an org's `usage.maxProjects` above can be computed live — it's not required or enforced on every project yet.

**What this does *not* do yet:** no route or query actually **filters** by `organizationId` — two organizations' users can currently see each other's data (roles, master data, etc). The columns and the assignment endpoints exist; enforcing isolation (filtering every query by `req.user.organizationId`) is a separate, larger follow-up.

## Projects & WBS

The first "dependent" tables (Phase 3 Project Core) — everything above this was an independent master.

**`projects`** — the "Input 1 — Project Information" Figma screen: `name`, `code?` unique, `type?`, `location?`, `client?`, `description?`, `startDate?`, `targetCompletionDate?`, `projectManagerId?`/`planningEngineerId?` (FK → `users`, the form's two dedicated dropdowns), `towers`/`floors` (simple string-array tag lists), and `status` (`draft` → `submitted` → `active`). This is **not** the same thing as `organizations`: an organization is the SaaS tenant company; a project is one of *their* actual construction projects (e.g. "Riverside Metro Interchange"). "Phases" (Figma's "Structure & Scope" panel) deliberately reuse the `wbs` tree as top-level nodes rather than a separate table — see below.

Access follows the Planning Module doc's "Main Users" for Project Information: **create/edit** = `admin`/`organization-admin`/`planning-manager` ("Project Admin / Planning Team"); **submit → active** approval = `admin`/`project-manager` ("Project Manager — reviews and approves"); **delete** = `admin`/`organization-admin`; **read** (including the activity log, team and documents below) = any authenticated user ("Other teams — consume the information").

| Method | URL | Auth required | Purpose |
| --- | --- | --- | --- |
| GET/POST | `/api/v1/projects?page=&limit=` | Read: any user · Write: admin/org-admin/planning-manager | List (paginated) / create |
| GET/PATCH/DELETE | `/api/v1/projects/:id` | Read: any user · Write: admin/org-admin/planning-manager · Delete: admin/org-admin | Read (includes `projectManager`, `planningEngineer`, `teamMembers`, `documents`) / update / delete (also removes its uploaded files from disk) |
| POST | `/api/v1/projects/:id/submit` | admin/org-admin/planning-manager | `draft` → `submitted` (`409` from any other status) |
| POST | `/api/v1/projects/:id/approve` | admin/project-manager | `submitted` → `active` (`409` from any other status) |
| GET | `/api/v1/projects/:id/activity?page=&limit=` | Any user | Paginated activity feed (who did what, newest first) — every create/update/status-change/team/document action below logs an entry here automatically |

**Project team** — the fuller roster shown in the detail view (PM, Planning Engineer, QA Engineer, ...), separate from the two dedicated FK fields above:

| Method | URL | Auth required | Purpose |
| --- | --- | --- | --- |
| GET/POST | `/api/v1/projects/:id/team` | Read: any user · Write: admin/org-admin/planning-manager/project-manager | List / add a member (body: `{"userId","roleOnProject","department"?}`) |
| DELETE | `/api/v1/projects/:id/team/:memberId` | admin/org-admin/planning-manager/project-manager | Remove a member (`404` if not on this project) |

**Reference documents** — local disk storage under `uploads/projects/` (gitignored; see `src/middlewares/upload.ts`), PDF/DWG/XLS/XLSX only, 20MB max:

| Method | URL | Auth required | Purpose |
| --- | --- | --- | --- |
| GET/POST | `/api/v1/projects/:id/documents` | Read: any user · Write: admin/org-admin/planning-manager/project-manager | List / upload (`multipart/form-data`, field `file`) — wrong file type → `400`, over 20MB → `413` |
| GET | `/api/v1/projects/:id/documents/:documentId/download` | Any user | Streams the original file back with its original filename |
| DELETE | `/api/v1/projects/:id/documents/:documentId` | admin/org-admin/planning-manager/project-manager | Deletes the DB record and the file on disk |

**`wbs`** — Work Breakdown Structure, a self-referencing tree per project (`Project → Tower → Floor → Work Package → ...`), matching the Planning Module doc's fields exactly: `code`, `name`, `parentId`, `description`, `discipline`, `location`, `responsibleTeam`, `status` (`draft` → `submitted` → `approved`). No separate "work package" table — in the approved UI, work packages are just deeper nodes in this same tree, not a distinct resource. Figma's "Structure & Scope" phases (Phase 1/2/3...) are just top-level `wbs` rows (`parentId: null`) for the same reason.

| Method | URL | Auth required | Purpose |
| --- | --- | --- | --- |
| GET | `/api/v1/wbs?projectId=&parentId=&page=&limit=` | Any user | List, optionally filtered to one project and/or one parent. `parentId=root` lists only top-level nodes; `parentId=<id>` lists that node's direct children |
| GET | `/api/v1/wbs/:id` | Any user | Read one node |
| POST | `/api/v1/wbs` | Admin | Create (body: `{"projectId","code","name","parentId"?,"description"?,"discipline"?,"location"?,"responsibleTeam"?,"status"?}`) |
| PATCH | `/api/v1/wbs/:id` | Admin | Update (`parentId: null` moves a node back to root) |
| DELETE | `/api/v1/wbs/:id` | Admin | Delete (children keep their `code`/data but their `parentId` is set to `null`, not cascade-deleted) |

`code` is only unique **within a project** (`@@unique([projectId, code])`) — two different projects can each have their own "1.1". A duplicate code in the same project returns `409`; an unknown `projectId` returns `400`.

## Shared response format

Use these helpers in every API:

```js
successResponse(res, 'User created successfully', user, 201);
errorResponse(res, 'Validation failed', 400, errors);
```

Success:

```json
{ "success": true, "message": "Users fetched successfully", "data": { "items": [], "pagination": { "page": 1, "limit": 20, "total": 0, "totalPages": 0 } } }
```

Error:

```json
{
  "success": false,
  "message": "Validation failed",
  "errors": [{ "field": "email", "message": "\"email\" must be a valid email" }]
}
```

`data` and `errors` default to `null`. HTTP status codes indicate the result. Invalid JSON returns 400, oversized bodies 413, an invalid encoded id 400, a reference to a record that doesn't exist (e.g. an unknown `roleId`) 400, missing routes 404, a real path with the wrong method 405, missing/conflicting records 404/409, and unexpected failures a generic 500. Internal database details are not returned to clients.

## File storage (S3)

Images go to Amazon S3; **project documents stay on local disk** (`uploads/projects/`).

| Endpoint | Who | What |
| --- | --- | --- |
| `POST` · `DELETE /api/v1/admin/organization/logo` | `admin.organization.manage` | Organization logo → `orgs/<orgId>/logo/<uuid>.<ext>`; returns the profile. The replaced file is deleted. `PATCH /admin/organization` ignores `logoUrl` |
| `POST` · `DELETE /api/v1/auth/me/avatar` | signed in | Your profile photo → `orgs/<orgId>/avatars/<uuid>.<ext>`; returns the session (`user.avatarUrl`) |
| `POST /api/v1/uploads/images` | signed in to an organization | Any image → `orgs/<orgId>/images/<uuid>.<ext>`; `201 { key, url, contentType, size, fileName }` |

Uploads are multipart with one `file`: JPG, PNG, WebP or SVG, up to 5 MB (`400` for other types, `413` when larger).
The database stores the object **key**; responses turn it into a URL: a signed URL (valid `AWS_S3_SIGNED_URL_TTL`
seconds) on the private bucket, or a plain URL under `AWS_S3_PUBLIC_BASE_URL` (e.g. CloudFront) when set. Older values
that are full URLs pass through unchanged. Until the four `AWS_*` credentials are set, uploads answer `503`.

```dotenv
AWS_REGION=ap-south-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_BUCKET=
AWS_S3_PUBLIC_BASE_URL=     # optional CloudFront / public base URL
AWS_S3_SIGNED_URL_TTL=3600
AWS_S3_PUBLIC_BUCKET=blenaxis-public-assets   # public brand assets (email logo)
```

The IAM user needs `s3:PutObject`, `s3:GetObject` and `s3:DeleteObject` on `arn:aws:s3:::<bucket>/*`.

**Email logo.** The email templates link directly to
`https://blenaxis-public-assets.s3.ap-south-1.amazonaws.com/brand/blenaxis-logo-email.png`, in a separate **public** bucket
(`AWS_S3_PUBLIC_BUCKET`, default `blenaxis-public-assets`), so the private bucket never needs a public policy. Emails are
opened long after they're sent, so this URL must be permanent and public (signed URLs expire); change the logo by
replacing the file, not the URL.
Re-upload the logo (from `assets/brand/`) with `npm run storage:upload-brand`.

Create the public bucket with
`npm run storage:create-public-bucket [-- <name>]` (default `blenaxis-public-assets`, in `AWS_REGION`). It creates the
bucket with ACLs disabled, allows a public-read bucket policy (read only: no listing or writes), and uploads the email
logo. It uses the `.env` AWS keys, which then need `s3:CreateBucket`, `s3:PutBucketOwnershipControls`,
`s3:PutBucketPublicAccessBlock`, `s3:PutBucketPolicy` and `s3:PutObject`.

## Security middleware

- **Helmet** sets standard security response headers (CSP, HSTS, no-sniff, etc.) on every response.
- **CORS** is closed by default (no cross-origin requests allowed). Set `CORS_ORIGIN` to a comma-separated allowlist to permit specific frontends.
- **express-rate-limit** throttles every `/api/v1` route per IP (`RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX`, default 100 requests per 15 minutes); requests over the limit get a `429` in the standard error format. `/api/v1/auth/signup` and `/login` additionally share a tighter limit (`AUTH_RATE_LIMIT_WINDOW_MS` / `AUTH_RATE_LIMIT_MAX`, default 10 per 15 minutes) to slow down credential-stuffing/brute-force attempts.
- `TRUST_PROXY` should only be enabled when the app runs behind a reverse proxy/load balancer, so `req.ip` (used for rate limiting) reflects the real client IP instead of the proxy's.

## Logs

Every request is logged to `logs/` (rotated daily, gitignored) as one JSON line per request — timestamp, level, `requestId`, `ip`, `method`, `url`, `status`, `responseTimeMs`, `userAgent`:

- `logs/combined-YYYY-MM-DD.log` — every request (kept 14 days). Normal requests log at `http`, `4xx` responses (bad input, 404s/405s, rate-limit `429`s) at `warn`, `5xx` at `error`.
- `logs/error-YYYY-MM-DD.log` — only `error`-level entries (kept 30 days), including the full stack trace for unhandled exceptions from `errorHandler.ts`.

In development, logs also print to the console. In production, only `info`-and-above prints to the console, but everything still goes to `logs/combined-*.log`.

**Crashes outside a request** (`server.ts`): an error thrown outside Express's request cycle — a fire-and-forget callback, a stray unhandled promise — never reaches `errorHandler.ts` on its own and would otherwise crash the process with a bare stack trace on stderr, nothing in `logs/`. `process.on('uncaughtException'/'unhandledRejection')` catch both, log them properly to `logs/error-*.log` first, then exit(1) — the process may be in a corrupted state after either, so a process manager (PM2/Docker/systemd) should restart it, not this process itself.

To investigate a user-reported issue or suspicious activity, grep the combined log by request id, IP, path, or status:

```bash
grep '"requestId":"<id-from-the-X-Request-Id-header>"' logs/combined-*.log   # trace one request
grep '"ip":"1.2.3.4"' logs/combined-*.log
grep '"status":429' logs/combined-*.log   # rate-limited clients
grep '"status":404' logs/combined-*.log   # route scanning / broken links
```

## Adding an API

1. Update `prisma/schema.prisma` (remember `@map` for multi-word column names, see [Database columns](#database-columns)).
2. Run `npm run db:migrate -- --name describe_change`, then `npm run db:generate`.
3. Add query functions in `models` (accept `{ skip, take }` if it's a list endpoint — see `src/utils/pagination.ts`), a Joi schema in `validators`, and a controller function with `try/catch { next(error) }`.
4. Use `successResponse(...)` on success; thrown/rejected errors reach `errorHandler.ts` automatically.
5. Add a route under `/api/v1` in the resource's route file; if it takes `:id`, add a `router.param('id', ...)` decode step (copy the pattern in `role.routes.ts`) and an `.all()` fallback per path for a proper `405`.

For changes needing SQL review before applying:

```bash
npm run db:migrate -- --name describe_change --create-only
# Review the generated migration.sql, then:
npm run db:migrate
npm run db:generate
```

Commit schema and migration files. Do not modify migrations already applied to shared databases. Review column renames, deletions, and required fields carefully when existing data is present.

## Commands

| Command | Purpose |
| --- | --- |
| `npm run dev` | Start `.ts` source directly with watching (via `tsx`) |
| `npm run build` | Compile `src/` to `dist/` for production |
| `npm start` | Run the compiled output (`dist/server.js`) |
| `npm run typecheck` | Type-check `src/` + `test/`, no emit |
| `npm run db:generate` | Generate Prisma client |
| `npm run db:migrate -- --name change_name` | Generate/apply local migrations |
| `npm run db:deploy` | Apply committed migrations during deployment |
| `npm run db:seed` | Create the standard role list, seed permissions, and grant them to `admin` |
| `npm run db:studio` | Open database browser |
| `npm run test:db` | Migrate `TEST_DATABASE_URL` and run the DB-backed tests |
| `npm test` | API contract tests, no live database or Firebase project required |

Deployment: install build dependencies, generate the Prisma client, apply committed migrations with `db:deploy`, run `npm run build`, then `npm start`. Supply credentials through the deployment environment. Do not run `db:migrate` in production.

## Pull request summaries

`.github/workflows/pr-summary.yml` writes each pull request's title and description from its branch, commits and
changed files (`.github/scripts/pr-summary.mjs`: the tenant app / super-admin / mobile script plus area rules for
this repo's folders). No AI and no secrets: it only uses git and GitHub's built-in token.

- On open, the title is set only while it's still GitHub's default (the branch name or a commit subject), e.g.
  `feat/project-access` → "Add project access". A title you wrote is kept; the suggestion appears in the description.
- The description lists the commits, the changed files grouped by area (API, models, middleware, migrations, schema and
  seed, email templates, tests, docs) and this repo's definition-of-done checklist. The section between the
  `pr-summary` markers is refreshed on every push; write your own notes above or below it.
- Add the `skip-pr-summary` label to leave a PR alone.
- Preview locally: `node .github/scripts/pr-summary.mjs --dry-run origin/dev`.

## Verification notes

`npm run typecheck`, `npm run build`, and all 62 tests (46 users/units/cost-codes/materials/equipment/labours/organizations/contractors/vendors/projects/wbs/health/docs/error-contract/roles + 10 auth/signup/login/refresh/logout + 6 SaaS Admin: onboarding/status/subscription/plans+modules/settings/invites/dashboard) passed during setup. Tests stub Prisma, `firebaseAuth`, and both Identity Toolkit REST calls (password sign-in and refresh-token exchange) directly — no live database or Firebase project needed to run them. The document-upload tests use real `multipart/form-data` requests against the real `multer` disk storage (a controlled, self-cleaning filesystem side effect, not a live dependency), deleting the written file in `t.after()`.

The full flow (real Postgres + real Firebase project) has also been verified manually end to end: signup creates a real Firebase user and DB row, login returns real tokens, every master's CRUD has been created + listed against the real database (including the `unit` relation on materials/equipment/labours), `PATCH /users/:id/organization` correctly assigns a real user to a real organization (and correctly `400`s on an unknown `organizationId`), the `uncaughtException` handler was verified directly (logs the error with a stack trace, exits with code 1), a real project ("Riverside Metro Interchange") and a real WBS hierarchy under it were created and filtered by `?parentId=`, the full Project Information flow (fields, team, document upload/download, `draft → submitted → active` workflow, activity log) was exercised end to end, the **entire SaaS Admin onboarding wizard** was run against blenaxis-super-admin's exact payload shape end to end (create → invite → accept → status/subscription changes → audit log, then cleaned up), the **super admin bootstrap and auth** flow was verified against real Firebase (self-healing reseed after simulating account deletion, login → refresh → me → logout), and — most recently — the **audit log rework** (renamed actions, `target`/`changes[]` shape matching blenaxis-super-admin's `AuditLogEntry` exactly, new `plan.created`/`plan.updated`/`settings.updated` logging) plus the new `GET /dashboard/summary` aggregation were checked against the real database (real organization data sanity-checked query-by-query against the live `organizations` table).

Dependency audit reports entries in Prisma's CLI dependency chain (`prisma`, `@prisma/config`, `deepmerge-ts`, `mysql2` — unused MySQL driver code, not applicable since this project uses Postgres) and a moderate `uuid` advisory several levels deep in `firebase-admin`'s Google Cloud client dependencies (`google-gax`/`@google-cloud/firestore`/`@google-cloud/storage`), none of which this project uses directly — only `firebase-admin/auth` is imported. Review upstream fixes before deployment; do not blindly run `npm audit fix --force`.
