# Technical documentation — ipw-backend

Evidence date: 2026-10-11. Revision: `main` `f5ff052` (2025-06-03).

Source of truth for behaviour is the tree at that revision. Secrets, connection strings, and key material are not copied here.

## Stack

| Item | Evidence |
|---|---|
| Language | TypeScript `^5.2.2` (`package.json`) |
| Runtime | README: Node.js 18+ recommended, npm 9+. `@types/node` `20.8.7`. No `.nvmrc` |
| HTTP | NestJS `^10.2.7` on `@nestjs/platform-express` |
| ORM | Prisma `^5.4.2`, client generated from `src/prisma/schema.prisma`, datasource MySQL |
| Auth | `@nestjs/passport`, `passport-local`, `passport-jwt`, `@nestjs/jwt` |
| Password hashes | `argon2` `^0.31.1` and `md5` `^2.3.0` |
| CSRF | `csrf-csrf` `^3.0.1` |
| Mail | `nodemailer` `^6.9.6`, templates `ejs` |
| Schedule | `node-schedule` `^2.1.1` |
| IDs in links | `sqids` `^0.3.0` |
| Uploads | Multer types; `FileInterceptor('file')`. No separate storage SDK |
| CSV | `fast-csv` `^4.3.6` |
| Process manager named in scripts | PM2 via `pm2 start *.config.js`. Config files are not in git |
| Tests | Jest `29.7.0`, `ts-jest`, Supertest. Specs only assert the provider is defined |
| Lint / format | ESLint 8, Prettier 3 |
| Quality gate in CI | SonarQube pipe `sonarsource/sonarqube-scan:3.0.2` |

`luxon` is imported only inside a comment in `UsersService`. `express-session`, `express-mysql-session`, and the CASL packages are dependencies and are not referenced by live code.

## Repository layout

```text
bitbucket-pipelines.yml
deploy/                 deploy.sh, Supabase DDL, CHANGELOG.md
migration/              001_example__action_timestamp.sql
nest-cli.json
package.json
random_sqids_salt.js
README.md
sonar-project.properties
sonarqube_scan.bat
src/
  main.ts
  app.module.ts
  admin/  auth/  audit/  common/  csrf/  email/  group/
  management/  member/  prisma/  public/  reports/  roles/
  upload/  users/
test/app.e2e-spec.ts
tsconfig.json
tsconfig.build.json
```

`nest-cli.json` source root is `src`. Build deletes the output directory and copies `email/template/` into the build as assets.

`random_sqids_salt.js` is a standalone script in the root. It is not imported by `src/`.

## Process startup

`bootstrap` in `src/main.ts`:

1. Creates a Nest Express application.
2. Sets global prefix `api`.
3. Parses `CLIENT_ORIGIN` as JSON and enables CORS with credentials.
4. Applies `cookie-parser` with `COOKIE_SECRET`.
5. Initialises `csrf-csrf` with `CSRF_SECRET` and cookie name `__${DOMAIN}-x-csrf-token`.
6. Patches `BigInt.prototype.toJSON` to `parseInt`.
7. Enables shutdown hooks.
8. If email `cronjob` id 1 is marked processing, clears that flag, then starts the email schedule.
9. Listens on `PORT` and `ADAPTER_IP`.
10. On `SIGTERM`, stops the email job and `process.exit()`. The TODO to wait for an in-flight send is not implemented. `process.exit()` is called without a code.

`ConfigModule.forRoot` loads `.env.${NODE_ENV}`. `NODE_ENV` must already be set for that path to resolve. The README says to copy an environment file; none is committed (no `.env*` in the tree that was inspected).

### Environment names

Declared on `ProcessEnv` in `src/main.ts` and listed in the README:

`NODE_ENV`, `DOMAIN`, `ADAPTER_IP`, `PORT`, `DATABASE_URL`, `SESSION_SECRET`, `CSRF_SECRET`, `COOKIE_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET`, `HASHID_SALT`, `CLIENT_ORIGIN`, `SMTP_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `UPLOAD_DIRECTORY`, `NUXT_API_LOCATION`, `NUXT_CLIENT_LOCATION`.

Also read by code and not declared on that interface: `SQIDS_SALT` (`UsersService.createHashidsInstance`).

Read by the README deploy section as a generic `.env` `DATABASE_URL` for `deploy/deploy.sh` (the script loads `.env`, not `.env.${NODE_ENV}`).

Not read by application code: `HASHID_SALT`, `UPLOAD_DIRECTORY`, `NUXT_API_LOCATION`, `SESSION_SECRET` (session block is commented out; the name is still destructured in `bootstrap`).

`CLIENT_ORIGIN` must be JSON with an `origin` property. `JSON.parse` runs at startup with no catch.

## HTTP surface

Prefix `/api`. Public routes: `POST auth/login`, `POST auth/reset`, `POST auth/refresh`, `POST auth/confirm/:type/:userHash/:token`, `GET csrf`.

Authenticated routes are the controllers `admin`, `user`, `role`, `report`, and `upload`, plus `POST auth/logout`. Empty controllers add no paths.

There is no `ValidationPipe`, no global exception filter beyond Nest defaults, and no rate limiter.

CSRF failures return JSON `{ status: 401, statusMessage: 'Unauthorized', message: 'INVALID_CSRF' }`.

## Authentication mechanics

Local strategy: `username` and `password` fields, `passReqToCallback`. `AuthService` is resolved per request.

Access strategy name `accessToken`. Extractor reads the signed cookie `__${DOMAIN}-auth` (two signatures separated by `.`) and `Authorization: Bearer <header>.<payload>`. It rejoins header, payload, and the access signature. `ignoreExpiration: false`. Secret `JWT_ACCESS_TOKEN_SECRET`.

Refresh strategy name `refreshToken` uses the second signature and `JWT_REFRESH_TOKEN_SECRET`, then `refreshTokens`.

`src/auth/constants.ts` exports `jwtConstants.secret`. `getTokens` does not use it.

Cookie helper `cookieOptions` (`src/common/constants/cookieOptions.constant.ts`): path `/`, domain `DOMAIN`, `sameSite: 'strict'`, `signed: true`, default max-age one minute. `httpOnly` and `secure` are true only when `NODE_ENV` is `staging` or `production`. `setClientCookies` then overrides `httpOnly` and `signed` per cookie, as described in the PRD.

`RolesGuard` returns true for `@Public()` and when no `@Roles` metadata is present. If `req.user` is missing on a role-checked route it calls `process.exit(1)`.

`findByIdWithRoles` attaches `roles` on the user object used as `req.user`. User class is `user_class_id` on that object. The unused user-class guard looks for `req.user.userClasses`, which this object does not set.

## Persistence

Prisma MySQL. `PrismaService` extends `PrismaClient` and `$connect`s on module init. Shutdown-hook subscription is commented out; the process uses `app.enableShutdownHooks()` instead.

Command `npm run prisma`: `npx prisma db pull && npx prisma generate`. The schema is a pull of an existing database, not a set of Prisma migrations.

Models that Prisma will not query: `admin_sessions`, `tmptbl` (`@@ignore`).

Tables written by this API:

| Table | Writer |
|---|---|
| `users` | create, update, delete, last logon, MD5 password |
| `user_password` | create on registration and on first successful MD5 login |
| `user_refresh_token` | create or update hash; logout sets null |
| `user_registration_token` | create on registration |
| `users_has_group` | staff create, admin add membership; delete on remove membership |
| `users_has_group_pending` | join; delete on cancel |
| `rbac_users_has_roles` | add, update, remove role |
| `user_group` | delete |
| `email` | queue and mark processed |
| `cronjob` | processing flag, id 1 only |
| `document_upload` | insert |

`user_reset_token` is in the schema and has no writer in `src/`.

Raw SQL is used for lists (users, groups, reports, audit, sessions, uploads). Several queries use `Prisma.sql` parameters. `getAvailableProfiles` uses `$queryRawUnsafe` and interpolates `user_id` and `assessment_year` into the string.

## File storage

`UploadService.getBasePath`:

- `NODE_ENV` `staging` or `production`: `resolve(__dirname, '..', '..', '..', 'ipw_uploads')` (three levels above the compiled file).
- Otherwise four levels above.

`saveDocumentUpload` writes with `writeFileSync` and a backslash between base and filename. `getDocumentPath` uses the same pattern. No MIME allow-list, size limit, or virus scan is in the service. Multer is the interceptor default (memory storage is what exposes `file.buffer`; a disk-storage engine would not). The code reads `file.buffer`.

## Email

`EmailService` builds a pooled nodemailer transport at construction. `verify()` would exit the process on failure and is not called.

Templates resolved from `__dirname/template/<name>.text.ejs` and `.html.ejs`. Nest assets copy `src/email/template/` on build. A comment in `send` still says the copy is TODO; `nest-cli.json` already lists the asset.

Schedule expressions: production and staging `*/1 * * * *`. Any other environment `5 * * * * *`.

`sendScheduled` sets `cronjob.processing` true and, on the normal path, does not set it false. The next tick returns immediately while the flag stays true. Startup clears a leftover true flag.

## Modules that compile and do nothing on the wire

`MemberModule`, `ManagementModule`, `PublicModule`, `AuditModule` are imported. Their controllers have a prefix and no methods. `GroupModule` exports a service used by `AdminModule`; its controller is empty. `AppController` is empty. `ServeStaticModule` still mounts.

`AppModule` imports `PrismaModule` twice.

## Deployment and CI

### Bitbucket Pipelines

`pipelines.branches.{main}` runs the SonarQube step only. Clone depth is full. Docker service memory is 3072. Variables `SONAR_HOST_URL` and `SONAR_TOKEN` come from repository or workspace settings. `sonar.projectKey` is `overdrive_ipw-backend_1bfa3ab2-b112-4f1e-bb4c-1eaabeb1eff1`. `sonarqube_scan.bat` is present for a local scan. Its host and token are not reproduced here.

The pipeline does not run `npm test`, `npm run build`, or `npm run prisma`.

### `deploy/deploy.sh`

Bash loop. Configuration block sets `REPO_NAME`, `SUPABASE_URL` (`https://supabase.appmonitor.co.za/`), and `SUPABASE_KEY` to placeholder strings. Port `9000` is hard-coded for stop and health check.

Steps when an unprocessed `code_deploy` row exists: kill the port, `git pull`, `mysqldump` using `DATABASE_URL` from `.env`, apply new files in `migration/` with the `mysql` client, record filename in `migrations`, `npm install`, `npm run start:production` in the background, curl localhost, up to three start attempts. Status and error are `PATCH`ed to Supabase.

`start:production` expects `production.config.js` in the working directory. That file is not in git, so a deploy on a clean checkout cannot start PM2 until the file is supplied outside git.

DDL in `deploy/*.sql` and the sample migration use PostgreSQL (`SERIAL`, `public`, `TEXT`). The `mysql` client in the same script would be pointed at MySQL.

README also says the script should be cron'd every 15 minutes. The script body is `while true` with no sleep between empty polls.

## Scripts

| Script | Command |
|---|---|
| `build` | `nest build` |
| `start` | `nest start` |
| `start:dev` | `nest start --watch` |
| `start:debug` | `nest start --debug --watch` |
| `start:devpm2` / `staging` / `production` | `pm2 start` the matching config file |
| `restart:staging` and `restart:production` | `npx prisma generate` then PM2 restart |
| `prisma` | `prisma db pull && prisma generate` |
| `test` | `jest` (rootDir `src`, regex `.*\.spec\.ts$`) |
| `test:e2e` | `jest --config ./test/jest-e2e.json` |
| `lint` | eslint `--fix` |
| `format` | prettier write on `src` and `test` |

## Tests

Unit specs under `src/**/*.spec.ts` compile a testing module with the class as the only provider and expect it to be defined. They do not call Prisma or HTTP.

`test/app.e2e-spec.ts` boots `AppModule` and expects `GET /` to return 200 and `Hello World!`. `AppController` defines no `GET`, and the running app also sets prefix `api` only in `main.ts`, which the e2e harness does not call. The spec does not match the application.

No coverage threshold is configured beyond Jest defaults. `test:cov` writes `coverage/`.

## Security-relevant implementation notes

These are observations for reviewers, not a recommended design.

- MD5 remains a successful login method whenever argon2 does not verify.
- `updatePassword` writes MD5 and does not rotate the argon2 row.
- Registration passwords are random and are not sent to the user. The confirm endpoint does not check the token.
- Several user routes have their `@Roles` comments disabled, so any valid access token can call them. See `business-rules.md`.
- `PATCH` updates throw `NO_CHANGE` before applying `password`, so a password-only update does not change the password.
- `RolesGuard` can `process.exit(1)` when role metadata is set and `req.user` is missing.
- Group search builds a SQL fragment that leaves a double quote open when the search parses as an integer.
- `getAvailableProfiles` interpolates values into `$queryRawUnsafe`.
- Download resolves a path from the database filename and `res.download`s it.
- CSRF cookie options use the one-minute default from `cookieOptions()`.
- SMTP options set `secure: false` and `tls.ciphers` to `SSLv3`.
- `deploy.sh` is written to pass the database password on the `mysql` and `mysqldump` command lines.

`jwtConstants` is unused by token signing. Do not treat it as the live JWT secret.

## Frontend contract

**Inferred** from this repository only.

- Client origin allow-list: `CLIENT_ORIGIN`.
- Confirmation URL host: `NUXT_CLIENT_LOCATION`.
- After login the JSON `redirect` is `/member/guidelines`.
- Cookies are named with `DOMAIN`. The client must send the unsigned header and payload in `Authorization` and the signed signature cookie.
- CSRF cookie name `__${DOMAIN}-x-csrf-token`. Token endpoint `GET /api/csrf`.
- Development static root expects a Nuxt `.output/static` directory beside this project, under `client/`.

The frontend repository is not named.

## Monitoring and workers

No APM, health controller, or metrics endpoint is in `src/`. Deploy health is `curl` of `http://localhost:9000` with no path.

The only in-process worker is the email `node-schedule` job. There is no separate queue process.

`GET /api/admin/member-activity` and `GET /api/admin/live-users` are the only operational reads.

## Git history relevant to structure

Nine commits on `main`, 2025-05-26 to 2025-06-03. `1aafc59` records a split of a server codebase into this backend. History before that split is not in this clone. Authors: Jaco Brits and Jason Wandrag.

## Related Silicon Ark

No earlier `silicon_ark/ipw-backend/` documents existed at the time of this write. Behaviour above is from this repository only.

Sibling sets already on `main`, used here only to name the overlap:

| Set | What it adds | How it relates |
|---|---|---|
| `ipw_new` | README phrase "Integrated Production of Wine"; Nest API under `server/` plus a Nuxt client | Same day as this repo's split commit `1aafc59`. Whether `ipw-backend` is that `server/` extracted is **Requires Confirmation** |
| `ipw-frontend` | Nuxt client calling `/api`, hosts port 9000, `staging.ipw.co.za`, `ipw.co.za` | Matches this API's prefix and the deploy script's port 9000. This repo does not name the hosts |
| `ipw-php` | CodeIgniter 4 IPW application | This repo's comments mention PHP and Joomla. Identity of that legacy app is **Unknown / Requires Confirmation** |
