# Business rules — ipw-backend

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

Rules below are taken from code, SQL, and the README. Where the code behaves a certain way and no document states it as policy, the rule is marked **Inferred** or **Implemented**. Comments that describe an intended check which the method does not perform are marked **Not implemented**.

No separate business-rules document exists in the source repository or in Silicon Ark.

## Identity

| Rule | Provenance |
|---|---|
| A person is a row in `users`. Username is unique (`unique_username`, max 25). Email is `VarChar(119)` and is not declared unique in Prisma | **Implemented** in `schema.prisma` |
| Two classes exist in code: `1` IPW staff, `2` IPW member | **Implemented** as `EUserClass` |
| Creating via admin sets class 1. Creating via `PUT /api/user/member` sets class 2 | **Implemented** in `UsersService.create`. The boolean parameter is named `ipwMember` and is true for staff |
| New users are stored with `status = 1`. The comment on that line says `0 = suspended` | **Implemented** assignment. The comment disagrees with login, which uses `suspended` for suspension and `status` for active |
| Login requires `status = 1` and `suspended = 0` | **Implemented** in both password lookups |
| Staff lists exclude `suspended = 2`. Member search requires `status = 1` and `suspended <> 2` | **Implemented** in `AdminService`. What value `2` means is **Unknown / Requires Confirmation** |
| `suspended_date` and `suspended_by` exist. No route in this revision sets them | **Implemented** columns. **Not implemented** as an action |
| The IPW organisation group id is the literal `1364`. A second excluded id is `7361`, commented as suspended users | **Implemented** literals in `UsersService`. **Requires Confirmation** that production still uses these ids |
| Staff creation also inserts `users_has_group` for group `1364` | **Implemented** |

## Passwords

| Rule | Provenance |
|---|---|
| Legacy passwords are MD5 hex in `users.password` (`Char(32)`) | **Implemented**. Comment in `checkOldPasswordStorage` attributes this to the previous PHP application |
| Current passwords are argon2 in `user_password.password` (`VarChar(97)`) | **Implemented** |
| Login tries argon2 for every `status = 1`, `suspended = 0` user matching username or email. If none verify, it tries MD5 | **Implemented** |
| A successful MD5 login creates `user_password` only when that user has no row yet | **Implemented** |
| A profile or admin password change writes a new MD5 to `users.password` and leaves `user_password` unchanged | **Implemented** in `updatePassword` |
| Because argon2 is tried first, a later MD5 change still works when the old argon2 hash does not verify: the MD5 branch runs and does not insert a second `user_password` row | **Inferred** from the control flow of `validateUser` |
| New accounts get a random 20-byte hex password stored both ways. That string is not put in the registration email | **Implemented** |
| `user_password.forced_password_update` defaults false. Nothing reads it | **Implemented** column. **Not implemented** rule. Comment in `AuthService` says the force-update was not built |
| Registration and reset tokens are random hex stored in plaintext | **Implemented** |
| Email templates say the link is valid for 1 day | **Documented** in the EJS files. **Not implemented** as a date check |
| `POST /api/auth/reset` does not create a reset token or send `reminder` | **Implemented** stub |
| `POST /api/auth/confirm/...` does not consume the registration token | **Implemented** stub |

## Sessions and tokens

| Rule | Provenance |
|---|---|
| Access token lifetime is 15 minutes. Refresh token lifetime is 1 day | **Implemented** in `getTokens` |
| The access cookie max-age is two minutes (`1000 * 60 * 2`). A comment on that line says 15 minutes | **Implemented** value. The comment is not the value |
| The signature cookie lasts one day and is always `httpOnly` | **Implemented** |
| Refresh is accepted only when argon2 verifies the presented refresh token against `user_refresh_token` | **Implemented** |
| Logout nulls that hash. It does not delete the row. A missing row would make `logout`'s `update` fail | **Implemented** |
| `ipw_sessions` is displayed as "live users". This process does not write it. Express session code is commented out | **Implemented** read. **Inferred** that the rows are left by another application |

## Authorisation

| Rule | Provenance |
|---|---|
| Role names in `ERole` must match `rbac_roles.name` | **Inferred** from `RolesGuard`, which compares `ur.name` to the enum string |
| Roles are loaded through `users` → `users_has_group` → `rbac_users_has_roles` → `rbac_roles`, distinct, ordered by `importance` descending | **Implemented** in `RolesService.findByUserId` |
| A route with `@Roles` allows the request when any required name is present | **Implemented** |
| A route without `@Roles` allows any authenticated user | **Implemented** |
| `@UserClasses` does not allow or deny requests, because its guard is not registered | **Implemented** |
| IPW Administrator is required for the whole `admin` controller | **Implemented** |
| System Developer or IPW Administrator is required to list users, read one user by the user controller, delete a user, and read `GET /api/role/user/:id` | **Implemented** |
| `GET /api/role/descriptions` requires Member Administrator and lists roles with `user_class_id` 2, excluding role id 1 | **Implemented** in `findDescriptions` |
| `POST /api/role/all-descriptions` requires System Developer or IPW Administrator and lists roles for the posted `user_class_id`, still excluding id 1 | **Implemented** |
| Document and report routes use the role lists in their controllers | **Implemented**. See `prd.md` WF-8 and WF-9 |
| `PUT /api/user/member` is not limited to an administrator | **Implemented**. A TODO says it should be. **Not implemented** |
| `PATCH /api/user/:id` is not limited to the signed-in user | **Implemented**. A TODO says it should be. **Not implemented** |
| `POST /api/user/cancel-pending-group` deletes by pending id and does not check `user_id` | **Implemented** |
| CASL is not applied | **Implemented** absence, despite the dependencies |
| RBAC privilege, domain, object, and action tables are not consulted by the guards | **Implemented**. They remain in the schema |

### Member role ids

**Implemented** in `UsersController.getGroupsAndRoles` and `AdminController.addMemberRole`.

| Id | Flag or meaning in code |
|---|---|
| 14 | `account_administrator` |
| 10 | `evaluation_form_manager` |
| 11 | `view_only` |
| 16 | `status_view_only` |

`add-member-role` rejects any other id with `400`. The names above are local flags, not a join to `rbac_roles.name`. **Requires Confirmation** that production ids still match.

On the self-service groups response, the role id applied to every group is the first `roleIds` element that has a `roles_id`, not the role for that `users_has_group_id`. **Implemented** in the `find` callback.

## Groups

| Rule | Provenance |
|---|---|
| A join request inserts `users_has_group_pending` with `status` 0, `status_changed_by` the caller, `status_changed_date` now | **Implemented** |
| The same user and group cannot have a second pending row or an existing `users_has_group` row. Error `EXISTS_GROUP_ID` | **Implemented** |
| Pending list hides `status = 1` | **Implemented**. No route sets pending `status` to 1. **Unknown** what status 1 means in data loaded from outside this API |
| Available groups omit 1364 and 7361 and omit groups already joined or pending | **Implemented** |
| Admin can insert `users_has_group` without that duplicate check | **Implemented** in `addGroupMembership` |
| Removing a membership deletes at most one `rbac_users_has_roles` row (the first found) and then the membership | **Implemented** |
| Group search keeps `organisation_type_id = 0` and requires a `user_group_sn` row (inner join) | **Implemented**. The meaning of organisation type 0 is **Unknown / Requires Confirmation** |
| If the search text parses as an integer, the SQL fragment is `sn.sawis_number = "` plus the like-pattern, with the quote not closed in the source template | **Implemented** text in `GroupService.findByQuery` |
| A group with any `group_eval_form` row cannot be deleted (`EXISTS_EVAL_FORM_ENTRIES`) | **Implemented** |
| Delete removes `user_group` only. It does not delete SAWIS numbers, memberships, or documents, and it does not write `auditlog` | **Implemented** |

## Evaluation status

**Implemented** only as labels in `UsersService.getAvailableProfiles`, for the latest `eval_form` whose own `status` is 10. Forms with `group_eval_form.status = 90` are left out of the label subquery.

| Status | Label |
|---|---|
| 0 | New |
| 10 | Busy |
| 20 | Submitted for Processing |
| 30 | Audit Busy |
| 50 | Pass |
| 51 | Pass Probation |
| 55 | Audit Pass |
| 60 | Fail |
| 61 | Probation |
| 65 | Audit Fail |
| 90 | Deleted/Removed |

**Inferred** from those strings: an evaluation moves through data capture, submission, audit, and a pass, fail, or probation outcome. This API does not perform those transitions.

Reports include only statuses 50 and 55 (**Implemented** in `ReportsService`).

Control-product groups (**Implemented** in the same SQL):

| `product_group_id` | Label |
|---|---|
| 1 | Herbicides |
| 2 | Pesticides |
| 3 | Fungicides |
| 4 | Nematicides |

Other ids produce a null label. `archive` on `control_product` is not filtered.

Report filters use `-1` as "all" for year, group, and product (**Implemented**).

CSV locale `en` uses English headers. Locale `afr` uses Afrikaans headers. Any other locale leaves the header row empty and still writes data rows (**Implemented**).

## Documents

| Rule | Provenance |
|---|---|
| Categories are filtered by `category_type` and ordered by numeric `position` | **Implemented** |
| Hidden categories are not filtered out (`hidden` is selected with `SELECT *`) | **Implemented** |
| A member's document list is limited to their `user_id` and the posted group | **Implemented** |
| An auditor-style list is every document for the posted group, plus related users | **Implemented** in `getAllDocuments` |
| Stored file name is uuid, hyphen, original filename | **Implemented** |
| `getDocumentUploadUUID` counts rows whose `uuid` equals the string `false` before it assigns a new uuid. If none exist, the generated uuid is kept | **Implemented** |
| Download loads any `document_upload_id` the caller is allowed to request. It does not check that the file belongs to the caller's group | **Implemented** in `getDocumentPath` |
| Upload directory is `ipw_uploads`, not `UPLOAD_DIRECTORY` | **Implemented** versus **Documented** in the README |

## Email

| Rule | Provenance |
|---|---|
| Registration mail subject is "Welcome to IPW" | **Implemented** in `registrationTitleReference` |
| HTML entities are encoded before EJS render | **Implemented** |
| One scheduler uses `cronjob` id 1 as a lock | **Implemented** |
| On the success path the lock is not cleared | **Implemented** (clear call commented). Process start clears it if it was left true |
| A row that throws is stored with `processed` true and an error payload | **Implemented** |
| SMTP `secure` is false | **Implemented** |

## Uniqueness and validation

| Rule | Provenance |
|---|---|
| Duplicate username on create or on username change: `EXISTS_USERNAME` | **Implemented** |
| Duplicate email on email change: `EXISTS_EMAIL`. Create does not check email uniqueness | **Implemented** |
| Update with no field differences: `NO_CHANGE`. A password-only body still hits `NO_CHANGE` before `updatePassword`, so the password is not written | **Implemented** in both update methods. The password block is after the empty-payload throw |
| Missing user id: `404` | **Implemented** |
| No global validation pipe and no class-validator rules on DTOs | **Implemented** |
| Join with a missing `user_group_id`: `INVALID_GROUP_ID` | **Implemented** |
| Cancel with a missing pending id: `INVALID_PENDING_GROUP_ID` | **Implemented** |

## Deploy and data change

| Rule | Provenance |
|---|---|
| Schema changes for the app are pulled from MySQL into Prisma. The app does not run Prisma migrations | **Documented** in the README (`npm run prisma`) and **Implemented** by the script |
| `deploy/deploy.sh` is written to apply raw SQL from `migration/` once per filename per `REPO_NAME` | **Implemented** in the script. `REPO_NAME` is still `your_repo_name` |
| The example migration and the Supabase DDL use PostgreSQL syntax | **Implemented** file contents. They do not match the MySQL datasource |
| The README says to run the deploy script every 15 minutes. The script is an infinite loop and also stops port 9000, while the Nest port is the `PORT` environment variable | **Documented** versus **Implemented** |

## Conflicts to confirm

1. README required variable `HASHID_SALT` versus code variable `SQIDS_SALT`.
2. README `UPLOAD_DIRECTORY` versus hard-coded `ipw_uploads`.
3. User-class decorator versus the guard that is actually registered.
4. Registration email and confirm route: a link is sent and the API does not honour it.
5. Password update writes MD5 only; login prefers an existing argon2 row.
6. `status` comment ("0 = suspended") versus `suspended` column used at login.
7. Access cookie lifetime versus JWT access lifetime.
8. PostgreSQL deploy SQL versus MySQL application database.
9. Empty `member` controller versus login redirect `/member/guidelines`.
