# Business rules — ipw_new

Evidence date: 2026-10-11.

Source: `git@bitbucket.org:overdrive/ipw_new.git`, `main` at `111772e`.

Rules below are taken from code, SQL notes, or the README. Each rule is marked **Documented**, **Implemented**, or **Inferred**. A rule that the README states and the code does not do is marked as a conflict, not rewritten to match the code.

No rule in this file was copied from `ipw-php`. Where a status code or group id looks like a legacy IPW rule, the meaning outside this repository is **Unknown / Requires Confirmation**.

## Programme

**Documented.** IPW is the Integrated Production of Wine scheme established by the South African wine industry in 1998. The site serves the public, industry participants, and IPW staff. Source: root `README.md`.

## Accounts

### BR-001 — Two user classes

**Implemented.** `EUserClass.IPWStaff = 1` and `EUserClass.IPWMember = 2`.

`users.service.create` sets `user_class_id` to 1 when the third argument is `true`, and to 2 when it is `false`. The parameter is named `ipwMember`. The call sites document the opposite of that name: `true` is staff, `false` is a member. The ternary matches the call-site comments, not the parameter name.

Staff creation (`PUT /api/admin/create-user`) also inserts `users_has_group` for group id `1364`. Member creation (`PUT /api/user/member`) does not attach a group.

### BR-002 — Staff group id

**Implemented.** `UsersService.ipwGroupID` is `1364`, commented as the database id for the IPW user group. Role add and remove for staff use this id.

**Requires Confirmation:** that this id is the staff group in the SharePoint `ipw_live.sql` database and in every environment.

### BR-003 — Active account required to log in

**Implemented.** Both password lookups require `users.status = 1` and `users.suspended = 0`.

`LocalStrategy` also throws `ForbiddenException` when `user.status === 0`. The lookup already excluded that status, so a suspended-by-status user fails as invalid credentials instead.

`AdminService.getAllUsers` lists staff with `suspended <> 2`. The meaning of suspended value `2`, as opposed to `0` or `1`, is **Unknown / Requires Confirmation**. It is not named in an enum.

### BR-004 — Username is unique

**Implemented** in Prisma: `users.username` has a unique index. Create member checks `findByUsername` and returns `EXISTS_USERNAME` before insert. Email is not checked for uniqueness in that method. Prisma does not mark `users.email` unique.

### BR-005 — New accounts get a random password

**Implemented.** `create` stores `md5` of 20 random bytes on `users.password` and Argon2 of the same bytes on `user_password`. The registration email contains a confirmation link and the first name. It does not contain the password.

**Inferred.** A person created this way cannot know the password until some other step sets one. That step is not implemented (the confirm handler is empty).

### BR-006 — Password storage

**Documented.** The README says to change the password column from `char(32)` to `varchar(97)` so it can hold an Argon2 hash.

**Implemented, differently.** `users.password` remains `Char(32)` in `schema.prisma` and is still the MD5 value. Argon2 is stored on `user_password.password` `VarChar(97)`, one row per `user_id`.

### BR-007 — Login password check order

**Implemented.**

1. Find users with `email = username OR username = username`, `status = 1`, `suspended = 0`. Verify Argon2 on their `user_password` rows.
2. If none match, find `users.username` equal to the submitted username (email is not accepted on this path), `status = 1`, `suspended = 0`, and `users.password` equal to MD5 of the submitted password.
3. On MD5 success, if no `user_password` row exists, create one with Argon2 of the same password.

**Inferred.** An old account that only has MD5 cannot log in by typing their email address. A migrated account can.

### BR-008 — Forced password change is unused

**Implemented as a column only.** `user_password.forced_password_update` defaults to false. The comment in `checkOldPasswordStorage` says the flag is not used and that the legacy upgrade instructions were not applied.

### BR-009 — Client password strength

**Implemented** in `client/composables/useValidation.js` via `owasp-password-strength-test`: minimum length 4, minimum phrase length 4, at least 2 optional tests, maximum length 128. The registration page uses this test.

**Conflict inside the client.** i18n in `i18n.config.ts` says first and last names must be less than 40 characters. `useValidation.js` allows title, first name, last name, telephone, and username up to 255 characters. Prisma limits are tighter: `firstname` and `lastname` 40, `username` 25, `title` 5, `tell` 25, `email` 119. The server DTO does not enforce those lengths.

### BR-010 — Confirmation link lifetime

**Documented** in both email templates: the link is valid for 1 day.

**Not implemented.** No comparison of `user_registration_token.created_date` or `user_reset_token.created_date` to the current time was found. The confirm route does not read the token.

## Roles and access

### BR-011 — Role names

**Implemented** as the `ERole` strings in `roles.service.ts`:

| Name | Used for |
|---|---|
| System Developer | Delete user, list users, report routes. Not on the admin controller class |
| IPW Administrator | Admin controller class, reports, uploads |
| IPW Auditor | Reports, uploads |
| IPW Content Manager | Reports |
| IPW Data Capturer | Reports |
| IPW Lead Auditor | Reports, uploads |
| Member Administrator | Reports, uploads, role description list |
| Edit Evaluation Forms | Reports |
| Member Reports | Reports |
| View Evaluation Forms | Reports |
| View Status Only | Reports |
| Document Uploads | Uploads. Not in `role.enum.ts` `RoleDefinition`, which is a second copy and also includes `Public` |

`RoleDefinition` in `role.enum.ts` is not the enum the guards import. The guard uses `ERole`. `Public` is commented out in `ERole` with a note that it might only have belonged to CodeIgniter.

**Inferred** from comments in `roles.service.ts`: `user_class_id` 1 on `rbac_roles` is staff-side roles and `user_class_id` 2 is member-side roles. `findDescriptions` defaults to `user_class_id` 2 and excludes role id 1.

The SQL notes insert one role, id 18, name `Document Uploads`, importance 10, `user_class_id` 2. That insert is a note in the SQL file, not a migration.

### BR-012 — How a role is stored

**Implemented.** Roles hang off `users_has_group`, not directly off `users`. `findByUserId` joins `users` → `users_has_group` → `rbac_users_has_roles` → `rbac_roles`. A user with no group link has no roles from this query.

### BR-013 — Missing role annotation means allow

**Implemented.** `RolesGuard` returns true when the handler has no `@Roles` metadata. Many user-controller role annotations are commented out, so any authenticated user can call those handlers.

`@Public` skips the role check. It also skips the access-token guard.

### BR-014 — Admin API role

**Implemented.** Every route on `AdminController` requires the role name `IPW Administrator`, including routes that do not add a second `@Roles`.

**Conflict with the client.** `isAdministrator.js` returns true for `System Developer` or `IPW Administrator`. The sidebar shows IPW user administration only for `isIpwAdmin()`. Other admin links (evaluation forms, member forms, probation, certificates, SAWIS) are not wrapped in `v-if="isIpwAdmin()"` in the lines reviewed; they sit in the logged-in block. Exact visibility still depends on the rest of `Sidebar.vue`'s `v-if` conditions around those lines.

### BR-015 — User class guard does not run

**Implemented metadata, not enforced.** `@UserClasses` is applied. The guard file `users.guard.ts` is never imported by `app.module.ts`. Global guards are only `AccessTokenGuard` and `RolesGuard`.

### BR-016 — Upload audience

**Implemented.**

- Categories, upload, and download: IPW Administrator, IPW Auditor, IPW Lead Auditor, Member Administrator, Document Uploads.
- All profiles and all documents: IPW Administrator, IPW Auditor, IPW Lead Auditor only.
- A member listing their own documents: Member Administrator or Document Uploads (not the auditor-only list).

The client shows the upload link when the user is an IPW admin, auditor, lead auditor, member administrator, or document-uploads role. That matches the upload route more closely than the "all documents" route.

### BR-017 — Report audience

**Implemented.** The five report handlers share one role list: System Developer, IPW Administrator, IPW Auditor, IPW Data Capturer, IPW Content Manager, IPW Lead Auditor, Member Administrator, Edit Evaluation Forms, Member Reports, View Evaluation Forms, View Status Only.

`Document Uploads` is not on that list.

## Groups, members, and reports

### BR-018 — Producer groups

**Inferred and implemented in queries.** Group search and report group names keep rows with `organisation_type_id = 0`. Other organisation types are excluded and not named in this code.

Group search joins `user_group_sn` (SAWIS number). A group with no SAWIS number row does not appear in `GroupService.findByQuery` because the join is inner.

**Implemented defect to record, not a rule to invent:** when the search text parses as an integer, the SQL fragment is `sn.sawis_number = "${applySearchValue})` with a stray quote. That condition is what the code contains.

### BR-019 — Joining a group

**Implemented.** `joinGroup` writes `users_has_group_pending` rather than `users_has_group`. `groupIsExists` is used to avoid a duplicate. Cancelling deletes or updates the pending row via `cancelPendingGroup`.

**Not found:** a handler that sets the pending row's `status` to an approved value and then creates `users_has_group`. Administrators can add a membership directly.

### BR-020 — Deleting a group

**Implemented.** `GroupService.remove` deletes `user_group` by id. `countEvailFormEntries` counts `group_eval_form` for that group. The admin delete handler consults that count. The exact rejection message is in `admin.controller.ts` and was not copied here as a product slogan: if the count is non-zero the delete is refused.

**Not implemented.** The audit-log write that the PHP comment describes.

### BR-021 — Which evaluation rows are reportable

**Implemented.** Control-product reports include a row only when `group_eval_form.status` is 50 or 55.

**Unknown / Requires Confirmation.** This repository does not define those statuses. Do not treat 50 or 55 as "approved" unless a human confirms the legacy meaning.

### BR-022 — Product groups

**Implemented** in the report SQL:

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

Other ids produce a null label from the `CASE`.

### BR-023 — Report filters

**Implemented.** Year, group, and control product use `-1` as "do not filter". CSV in locale `afr` uses Afrikaans headers (`Groep`, `Produk Groep`, `Handelsnaam`, `Aktiewe Bestanddeel`, `Formulering`). Any other locale, including `en`, uses the English headers only when `locale` is exactly `en`. A missing or other locale produces an empty header row and still writes data rows.

Group names for the report are limited to groups the user belongs to (`users_has_group.user_id`).

## Documents

### BR-024 — Document categories

**Documented as data notes** in `ipw table adjustments.sql`, not as application logic. Categories are typed `Farm` or `Cellar`, ordered by `position`, with English and Afrikaans names. Examples include IPW training certificates, spray records, and cellar zoning and wastewater documents. `hidden` defaults to 0.

The API returns categories for the requested `category_type`, ordered by numeric `position`. It does not filter `hidden` in `getCategories`.

### BR-025 — Who a document belongs to

**Implemented.** A `document_upload` row stores `user_group_id`, `user_id`, `document_category_id`, original name, MIME type, size, and a UUID. The member list filters by both group and the calling user. The staff "all documents" list filters by group only.

No rule in code restricts MIME type or size.

## Email

### BR-026 — Mail is queued

**Implemented.** `EmailService.create` inserts `email` with `processed = false`. A scheduler drains unprocessed rows.

### BR-027 — Schedule

**Implemented.** Every minute when `NODE_ENV` is `staging` or `production`. Otherwise at second 5 of every minute.

### BR-028 — Single worker row

**Implemented.** Processing state is `cronjob` id 1. Startup clears `processing` if it was left true.

The SQL notes include one example row dated `2023-06-04 22:13:55`. That is sample text in the SQL file, not proof of a live job.

## Sessions and activity

### BR-029 — Live users are legacy sessions

**Implemented as a read.** `getLiveUsers` selects `ipw_sessions` ordered by `last_activity`. This Nest application does not create those rows. If nothing else writes the table, the screen is empty or stale.

### BR-030 — Member activity is the legacy audit log

**Implemented as a read** of the latest 50 `auditlog` rows. This application does not insert audit rows.

## Internationalisation

### BR-031 — Two languages

**Implemented.** `en` and `afr`. Default locale in `i18n.config.ts` is `en`.

Public PDFs are filed under `eng` and `afr` directory names. The PDF folder uses `eng`. The Vue locale uses `en`. They are not the same code.

## Conflicts that need a human decision

| Topic | README or template | Code |
|---|---|---|
| Server framework | Fastify | NestJS on Express |
| Password column | Widen `users.password` to `varchar(97)` | Separate `user_password` table; `users.password` stays `char(32)` |
| Database setup | Import `ipw_live.sql` from SharePoint | File not in git. Prisma schema is the structural evidence |
| Prisma client output | README says `output = "./generated"` | `schema.prisma` has no `output` |
| Registration | Page exists for new users | Page calls the login API |
| Reset link valid 1 day | Email template | Reset email is not sent; expiry is not checked |
| Developer setup | "Make sure you have NestJS CLI installed" and `npm run start` | Client README says `npm run dev`, which is not a script. Client script is `start` (`nuxi dev`). Server `start` is `nest start` |
| Contributing remotes | GitHub `WeboonTechnologySolution` staging and production | This clone's origin is Bitbucket `overdrive/ipw_new`. Only `main` exists |
| Env file | README says create `.env` from `.env.template` | `ConfigModule` loads `.env.${NODE_ENV}` |
| Sqids salt | Template key `HASHID_SALT` | Code reads `SQIDS_SALT` |
| Client location for email links | Not in the server template | Code reads `NUXT_CLIENT_LOCATION` |
