# Business rules — ipw-frontend

Evidence date: 2026-10-11.

Source: `git@bitbucket.org:overdrive/ipw-frontend.git`, `main` at `798bd76`.

This repository is a browser client. It enforces the rules below before or while calling an API. It cannot show rules that exist only on the server. Each rule is marked **Implemented** (visible in this tree) or **Inferred** (a reasonable reading of names or constants, not a written policy).

No business-rules document was in the repository or in `silicon_ark/ipw-frontend/` before this file.

## Identity

### BR-001 — Two user classes

**Implemented.** `composables/useAuth.js` reads `user_class_id` from the refresh token.

- `1` → `isIpwStaff()`
- `2` → `isIpwMember()`

**Inferred.** The function names mean IPW staff and IPW member. This file does not define other class ids. A missing or other id is neither staff nor member.

The sidebar uses these functions for the welcome line, the profile picker, document behaviour, and the guidelines/codings links.

`composables/useRoles.js` also exports `isIpwStaff` and `isIpwMember`, with different meanings (unions of role ids). The sidebar does not use those two functions. Do not treat the two files as the same rule.

### BR-002 — Administrator names

**Implemented.** `middleware/isAdministrator.js` allows the route only when the access token's `roles` array contains an object whose `name` is `System Developer` or `IPW Administrator`.

**Inferred.** Those two names are the staff administrators. This file does not map them to numeric ids.

### BR-003 — Role ids used by the menu and helpers

**Implemented** in `composables/useRoles.js`. A user has a capability when `roles` contains that id.

| Id | Helper | What the sidebar does with it |
|---|---|---|
| 2 or 5 | `isIpwAdmin` | Shows "IPW Administration" |
| 8 | `isIpwAuditor` | Included in the document-upload link |
| 9 | `isDataCapturer` | Helper only. Not used in the sidebar |
| 10 | `isEditEvaluationForms` | Helper only |
| 11 | `isViewEvaluationForms` | Helper only |
| 12 | `isIPWContentManager` | Helper only |
| 14 | `isMemberAdministrator` | Included in the document-upload link |
| 15 | `isIPWLeadAuditor` | Included in the document-upload link |
| 16 | `isViewStatusOnly` | Helper only |
| 17 | `isMemberReports` | Helper only. The reports link is shown to every signed-in user |
| 18 | `isDocumentUploads` | Included in the document-upload link |

**Inferred.** The helper names are the business names of those ids. The display strings are not in this file. Whether id 2 is `System Developer` and id 5 is `IPW Administrator` (or the reverse) is **Unknown / Requires Confirmation**.

### BR-004 — Who may open a document upload

**Implemented** as two different checks.

- The sidebar shows the link when the user is an IPW admin (ids 2 or 5), auditor (8), lead auditor (15), member administrator (14), or document uploader (18).
- The page `pages/upload/documents.vue` only requires a valid login.

**Inferred.** The menu is the intended audience. The page does not repeat it.

Inside the page, staff (`user_class_id` 1) pick any profile from the upload API. Everyone else is tied to the profile stored in `localStorage`.

## Session

### BR-005 — Login destination

**Implemented.** After HTTP 200 the client navigates to `body.redirect` from the API. It does not send members to a fixed page of its own. A person who is already signed in and opens `/login` is sent to `/member/guidelines`.

### BR-006 — Password accepted by the login form

**Implemented.** `owasp-password-strength-test` is configured with `minLength` 4, `maxLength` 128, `minPhraseLength` 4, `minOptionalTestsToPass` 2, and `allowPassphrases: true`. The login button stays disabled until `owasp.test(password).strong` is true.

**Inferred.** This is a client gate only. The API may accept or reject a different set. That server rule is not in this repository.

### BR-007 — Username length on login and reminder

**Implemented.** `isValidUsername` is length from 1 to 255 characters. The create-user screen uses a tighter cap of 25 (BR-012).

### BR-008 — Logout

**Implemented.** The UI logs out only when the API returns 204. The selected profile is then removed from `localStorage`.

### BR-009 — Expired session

**Implemented.** An expired access token is refreshed if the refresh token's `exp` is still in the future. Failure throws `LOGIN_EXPIRED` and the calling page navigates to `/login`.

**Inferred.** The client treats the JWT `exp` claim as unix seconds (`payload.exp * 1000`).

## Member profile

### BR-010 — A member works against one group profile

**Implemented.** When a member signs in, the client loads `GET /api/user/available-profiles`.

- One profile: it is selected automatically.
- More than one: the member must choose. "Change profile" is shown only when there is more than one.
- The choice is `localStorage` key `selected_profile` (JSON with at least `user_group_id` and `group_name`).

**Implemented.** If `statusdesc` is present it is appended to the option label. If `status === 20` and `statusdesc` is present, the same label is used (a separate template branch with the same text).

**Unknown.** What status 20 means. The client does not hide status 20.

### BR-011 — Contact-detail conflicts

**Implemented** on save of `/member/contact-details`:

| HTTP | `body.message` | What the user sees |
|---|---|---|
| 400 | `EXISTS_USERNAME` | Username unavailable (page string) |
| 400 | `EXISTS_EMAIL` | Email-exists string |
| 400 | `NO_CHANGE` | No changes to apply |
| 403 | `error === 'Forbidden'` | Forbidden |
| 200 | — | Success. A non-empty password field is cleared |

Other failures use the generic internal-error string.

## Staff data entry

### BR-012 — New IPW user

**Implemented** on `pages/admin/ipw-users/create-user.vue`.

- Email is required and must match the page's regex (overall length 6–254, local part up to 64, domain labels).
- Username is required and at most 25 characters.
- First name longer than 40 characters is rejected. Empty first name is allowed by this computed.
- Last name is checked with `form.firstname.length`, so a long last name is not rejected on its own.
- Telephone may be blank. A non-blank value must parse as a valid number for region `ZA` and is rewritten to international format on blur or Enter.
- Title is not validated.

The same ZA phone rule is on edit-user and edit-member.

### BR-013 — Member search

**Implemented.** Find-member sends `filter` (`All` or `A`–`Z`) and `search`. `suspended === 1` is drawn as suspended. Every other value is drawn as active.

**Inferred.** `1` means suspended because the image `alt` is "Suspended". Other codes are not distinguished.

### BR-014 — Create member

**Implemented.** `PUT /api/user/member` is treated as success only on HTTP 201. HTTP 400 `EXISTS_USERNAME` is shown. A TODO says the user should then be sent to administration to add roles. That redirect is not established here as implemented behaviour beyond the comment.

### BR-015 — Group delete

**Implemented.** `DELETE /api/admin/group` with `{ user_group_id }`. HTTP 204 is success with no body. Other statuses parse JSON.

## Documents

### BR-016 — Document type

**Implemented.** The only type values the form sends are the strings `Farm` and `Cellar`. Categories are loaded for that `category_type`. The category label is `category_name_en` or `category_name_afr` according to the current locale, prefixed with `position`.

### BR-017 — Upload is complete only on 204

**Implemented.** Any other status, including a JSON error body, is shown as an internal error. The form is cleared and the list reloaded only after 204.

### BR-018 — Download name

**Implemented.** The saved file name is `IPW_Document_` + the UTC date `YYYY-MM-DD` from `new Date().toJSON().slice(0, 10)` + `_` + `originalname`.

The request path is `/api/upload/download-document/{document_upload_id}`.

## Reports

### BR-019 — "All" is -1

**Implemented.** Year, group, and control product default to `-1`, labelled as the all-option in the page strings. Changing any filter clears a previously loaded table.

### BR-020 — CSV name and language

**Implemented.** The file is `IPW_Report_{YYYY-MM-DD}.csv` using the same date slice as documents. The POST body includes `locale` (`en` or `afr`). The table request does not add `locale`.

## Language and public content

### BR-021 — Locale

**Implemented.** Supported locales are `en` (default) and `afr`. Document titles use `title` or `titel` on the page meta. There is no browser-language detection in `i18n.config.ts`.

### BR-022 — Guideline language files

**Implemented.** PDF helpers switch folder `eng` vs `afr` with the locale.

**Implemented exception.** `calcBiodiversityHref` returns the English file for both locales.

## Registration

### BR-023 — Self-registration is not a completed rule

**Implemented.** The registration page tells the visitor about IPW and links to `https://www.sawis.co.za/forms/sawis.php`. The in-app submit does not call a registration endpoint (it calls login) and then reads fields that are not on the form.

**Inferred.** The intended path may be the SAWIS form plus a later confirmation URL. The confirmation handlers are empty, so that path is not a rule this client enforces.

## Shell screens

### BR-024 — Sample rows are not live data

**Implemented.** Evaluation forms, member forms, probation, certificates, SAWIS, and add-group render fixed example content (including "1,3 dichloropropene" and a 2022 farm evaluation row). They are not evidence of current members, scores, or products.

## Rules that are not in this repository

The following are **Unknown** here and must not be copied in from another IPW codebase without a new look at that code:

- How passwords are stored, and whether MD5 or Argon2 is accepted.
- Which roles the API itself checks.
- Scoring, probation, certificates, and SAWIS compliance.
- Who may approve a pending group join.
- File type and size limits for uploads.
- How long access and refresh tokens live, aside from the client honouring `exp`.
