# PRD — ipw_new

Evidence date: 2026-10-11.

Source: `git@bitbucket.org:overdrive/ipw_new.git`, `main` at `111772e` (2025-05-26).

There is no prior PRD in Silicon Ark for this repository. Requirements below are recovered from the root README, the client README, Nest controllers and services, the Prisma schema, the Nuxt pages, and the eight commits on `main`. Provenance is labelled. Secrets in `.env.template` are empty placeholders and are not copied here.

A separate Silicon Ark set, `ipw-php`, documents a CodeIgniter 4 IPW application. This PRD does not copy requirements from that set. Where the two repositories appear to cover the same programme, that overlap is marked **Unknown / Requires Confirmation**.

## Product purpose

**Documented.** The root `README.md` says the product is a rewrite of the old IPW website and portal. It informs the public about Integrated Production of Wine and gives members and staff tools to maintain membership and administer the scheme.

**Implemented.** A Nuxt static site plus a NestJS API (`/api`) on top of an existing MySQL schema. Public pages are compiled into the client. Member and staff actions call the API.

**Documented, and not what the code is.** The README calls the server Fastify. The code is NestJS on Express.

## Users and interactions

| Actor | Evidence | Status |
|---|---|---|
| Public visitor | Pages under `client/pages/` with no `is-logged-in` middleware: home, cellars, about, manuals, forms, literature, audit, contacts, courses, climate, greenhouse gas, environment, links, contact | **Implemented** as static Vue pages and PDFs under `client/public/documents/` |
| Person with a `users` row | `POST /api/auth/login` via `LocalStrategy` | **Implemented.** Login requires `status = 1` and `suspended = 0` |
| IPW member (`user_class_id` 2) | Sidebar links for guidelines and codings when `isIpwMember()` | **Implemented** in the client. Several member API routes have their `@Roles` comments disabled, so the API allows any authenticated user |
| IPW staff (`user_class_id` 1) | `EUserClass.IPWStaff = 1` in `users.service.ts`. Admin create-user sets this class | **Implemented** as a stored value. The guard that reads `@UserClasses` is not registered |
| IPW Administrator | Role name `IPW Administrator`. Class-level `@Roles` on `AdminController`. Client middleware also treats `System Developer` as an administrator | **Implemented**, with a client/server mismatch recorded under FR-012 |
| IPW Auditor, Lead Auditor, Content Manager, Data Capturer | `ERole` in `roles.service.ts` | **Implemented** as role-name strings used on report and upload routes |
| Member Administrator, Document Uploads, evaluation-form roles, Member Reports, View Status Only | Same enum, `user_class_id` comment `2` on several | **Implemented** as role checks on selected routes |
| Operator who starts the API | `server/package.json` scripts, `main.ts` listen | **Implemented.** Who runs production is **Unknown** |

No captcha, no second factor, and no session table write were found in the Nest login path. `getLiveUsers` reads the legacy `ipw_sessions` table. Nothing in `server/src` inserts into `ipw_sessions`.

## Systems the product talks to

1. MySQL through Prisma (`DATABASE_URL`). Template database name `ipw_development`. The README points developers at a SharePoint file `ipw_live.sql`, which is not in git.
2. SMTP through Nodemailer (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`). The provider is not named.
3. The browser, as a static Nuxt app. Staging and production build scripts set `NUXT_API_LOCATION` to `https://staging.ipw.co.za` and `https://ipw.co.za`.
4. Local disk for uploads (`UPLOAD_DIRECTORY`, template value `../../../uploads`).
5. Bitbucket Pipelines for a SonarQube scan. No deploy step.
6. Confirmation links are built as `${NUXT_CLIENT_LOCATION}/confirm/${type}/${hash}/${token}`. `NUXT_CLIENT_LOCATION` is not in `server/.env.template`.

No SAWIS, BIS, or payment client exists under `server/src`. SAWIS tables are in the Prisma schema. SAWIS Vue pages have their API imports commented out.

## Major workflows

### WF-1 — Public information

**Trigger.** Visitor opens a public route.

**Implemented.** The page renders Vue copy and, on several pages, links to PDFs in `client/public/documents/` split by `eng` and `afr`. No API call.

**Documented** in `client/README.md` route list. That list names `member/profile`, `member/logout`, `home`, and `confronting-climate-change`. Those files are not in `client/pages/`. The climate page that exists is `climate-change.vue`.

### WF-2 — Login

**Trigger.** `login.vue` posts to `auth.login`, which calls `POST /api/auth/login`.

**Implemented.**

1. Client sends CSRF header from `GET /api/csrf`.
2. `LocalStrategy` calls `AuthService.validateUser`.
3. Argon2 is checked first against `user_password` for a row whose `email` or `username` matches, with `status = 1` and `suspended = 0`.
4. If that fails, MD5 of the password is compared to `users.password` for the same `username` (not email), `status = 1`, `suspended = 0`. On success, if no `user_password` row exists, one is created with Argon2.
5. Tokens are signed. Signatures go in an httpOnly cookie. Header and payload go in readable cookies. The JSON body includes `redirect: '/member/guidelines'` and the tokens.
6. `is-not-logged-in` sends an already logged-in visitor to `/member/guidelines`.

**Not implemented.** Forcing a password change. `user_password.forced_password_update` exists. A comment in `checkOldPasswordStorage` says the flag is not used.

### WF-3 — Logout and refresh

**Implemented.** `POST /api/auth/logout` sets `user_refresh_token.refresh_token` to null and clears cookies. It requires an access token (it is not `@Public`). `POST /api/auth/refresh` is `@Public`, uses `RefreshTokenGuard`, and re-sets cookies from tokens already on the request.

### WF-4 — Password reminder

**Trigger.** `reminder.vue` posts the form to `POST /api/auth/reset`.

**Implemented.** The controller accepts `ResetPasswordDto` and returns `{ redirect: '/reset-instructions' }`. It does not look up the user, write `user_reset_token`, or queue the reminder template.

**Documented in the email template only.** `reminder.text.ejs` says a reset link is valid for 1 day. No code path sends that template. `generateResetToken` exists and is not called from a controller.

### WF-5 — Registration and confirmation

**Documented.** README and client README list a registration page. `users.service.create` builds a registration email with a confirmation link and says the link is for verifying the address. The email template says the link is valid for 1 day.

**Implemented, and incomplete.**

- `PUT /api/user/member` creates a member (`user_class_id` 2) after rejecting a duplicate username. It stores a random password (MD5 on `users.password`, Argon2 on `user_password`), writes `user_registration_token`, and queues a registration email. The random password is not included in the email.
- `PUT /api/admin/create-user` does the same with the staff flag, so `user_class_id` is 1 and the user is attached to group id `1364`.
- `registration.vue` does not call either endpoint. `submitForm` calls `auth.login(form)` with the registration fields.
- `POST /api/auth/confirm/:type/:userHash/:token` is `@Public` and returns without reading the token.
- `client/pages/confirm/[type]/[hash]/[token].vue` collects a new password and does not call the API.
- No code compares `created_date` to a one-day limit.

### WF-6 — Member self-service

**Implemented** for an authenticated caller, because the `@Roles` lines on these handlers are commented out:

- `GET /api/user/contact-details`
- `GET /api/user/groups-and-roles`
- `GET /api/user/available-groups`, `POST /api/user/join-group`, `GET /api/user/pending-groups`, `POST /api/user/cancel-pending-group`
- `GET /api/user/available-profiles`

Client pages `member/contact-details.vue` and `member/groups-and-roles.vue` call the user API. `member/guidelines.vue` and `member/codings.vue` are logged-in static document pages.

`GET /api/user/username-exsits`, `POST /api/user/select-profile`, and `GET /api/user/selected-profile` are commented out in the controller.

### WF-7 — Staff administration

**Implemented** on `AdminController`, class-level role `IPW Administrator`:

- list and fetch staff users (`user_class_id = 1`, `suspended <> 2`)
- create and update a user
- list member activity from `auditlog` (latest 50 rows)
- list `ipw_sessions` as live users
- add and remove a role on group `1364`
- search members, groups and roles, available groups
- add and remove group membership
- list groups and delete a group when `GroupService.countEvailFormEntries` allows it (the delete handler is in `admin.controller.ts`)

Client pages that call this API include IPW users, find member, live users, member activity, and member registrations. Add-group, edit-group, certificates, probation, and both SAWIS pages import the admin API only inside comments.

### WF-8 — Control-product report

**Implemented.** `GET /api/report/years`, `group-names`, and `control-products`, plus `POST /api/report/table-report` and `csv-report`. Rows come from `group_eval_form` joined to `eval_form`, `group_eval_form_appendix`, and `control_product`, and only where `group_eval_form.status` is 50 or 55. Product groups are mapped in SQL: 1 Herbicides, 2 Pesticides, 3 Fungicides, 4 Nematicides. CSV headers switch on `locale` `en` or `afr`. A comment in the controller says the attachment filename does not reach the browser.

`member/reports.vue` calls the report API and requires login. It does not check a report role. The API does.

### WF-9 — Document upload

**Implemented.** Categories come from `document_category`. A file is written under `UPLOAD_DIRECTORY` as `{uuid}{extension}` and a `document_upload` row is stored. Download uses `res.download`. There is no file-type or size check in `UploadService`. `GET /api/upload/download-document` reads the id from the body. A GET with a body is what the controller is written to do.

`upload/documents.vue` and `management/list-members.vue` call the upload API.

### WF-10 — Evaluation forms, probation, certificates, audits

**Documented** by navigation in `Sidebar.vue` and by page titles.

**Not implemented as API behaviour.** No controller exposes eval-form, probation, certificate, or audit routes. `AuditController`, `MemberController`, `ManagementController`, `GroupController`, and `PublicController` are empty. Prisma models for those areas exist because the schema was pulled from the existing database (`admin.service` and `schema.prisma`).

`GroupService.remove` contains a commented PHP-style audit-log block and a `TODO: Audit log`. The Nest code does not write `auditlog`.

## Functional requirements

### FR-001 — Public IPW information

The site shows IPW information without login.

- **Who:** public visitor.
- **Trigger:** open a public page.
- **Output:** HTML in the Nuxt page, plus linked PDFs where that page lists them.
- **Provenance:** Documented (README, client README) and Implemented (pages and `client/public/documents`).
- **Evidence:** `client/pages/index.vue`, `about-us.vue`, `manuals.vue`, `audit.vue`, and the document folders.
- **Confidence:** High for the pages that exist. The client README's file names are partly stale.

### FR-002 — English and Afrikaans

The interface has English (`en`) and Afrikaans (`afr`) strings.

- **Provenance:** Implemented in `client/i18n.config.ts` and in per-page `useI18n` blocks. Document categories store `category_name_en` and `category_name_afr`.
- **Evidence:** `client/i18n.config.ts`, `document_category` in the SQL file and Prisma schema.
- **Constraint:** the locale code is `afr`, not `af`.
- **Confidence:** High.

### FR-003 — Login against legacy and new password storage

A person can sign in with a username and password when the account is active and not suspended.

- **Inputs:** `username`, `password` (Passport local defaults).
- **Behaviour:** Argon2 on `user_password` first (match email or username); else MD5 on `users.password` (match username only). Successful MD5 login copies the password to Argon2 when missing.
- **Output:** cookies and JSON `{ redirect: '/member/guidelines', access_token, refresh_token }`.
- **Errors:** `UnauthorizedException('INVALID_CREDENTIALS')` when no user matches. `ForbiddenException` if `user.status === 0` after validation. Validation already requires `status = 1`, so the forbidden branch is not reached by the current query.
- **Provenance:** Implemented. The README documents the Argon2 column-width change. The MD5 fallback is only in code.
- **Evidence:** `auth.service.ts` `validateUser`, `local.strategy.ts`, `auth.controller.ts` `login`.
- **Confidence:** High.

### FR-004 — Token lifetime

Access tokens expire in 15 minutes. Refresh tokens expire in 1 day. The signature cookie max age is 1 day. The access-payload cookie max age is 2 minutes (`1000 * 60 * 2`). The comment above that line says 15 minutes.

- **Provenance:** Implemented. The comment disagrees with the code.
- **Evidence:** `auth.service.ts` `getTokens` and `setClientCookies`.
- **Confidence:** High.

### FR-005 — Logout

Logout clears the stored refresh-token hash and the auth cookies.

- **Provenance:** Implemented.
- **Evidence:** `auth.controller.ts` `logout`, `auth.service.ts` `logout` and `clearClientCookies`.
- **Error:** if no `user_refresh_token` row exists, `update` throws. That path is not caught in the controller.
- **Confidence:** High.

### FR-006 — CSRF on API calls

State-changing and authenticated calls send `x-csrf-token` from `GET /api/csrf`.

- **Provenance:** Implemented.
- **Evidence:** `main.ts` double-CSRF middleware, `csrf.controller.ts`, `client/api/auth.js`.
- **Confidence:** High. A comment in the CSRF controller asks why a new token is generated on each request. That question is unanswered in the repo.

### FR-007 — Create a member account

An authenticated caller of `PUT /api/user/member` can create a user with class 2 when the username is free.

- **Inputs:** `CreateUserDto` fields `title`, `firstname`, `lastname`, `telephone`, `email`, `username`. No class-validator decorators are on the DTO. `ValidationPipe` is not registered in `main.ts`.
- **Behaviour:** random password, registration token, queued registration email. The new member is not attached to a producer group (`users_has_group` is created only when the third argument is `true`).
- **Error:** `BadRequestException('EXISTS_USERNAME')`.
- **Provenance:** Implemented on the API. The public registration page does not call it (see FR-008). A TODO in the controller says to forbid callers who are not a member admin or IPW administrator. That check is absent, and the role annotations on this handler are commented out.
- **Evidence:** `users.controller.ts` `createMember`, `users.service.ts` `create`.
- **Confidence:** High.

### FR-008 — Public registration screen

The registration screen is a documented route.

- **Provenance:** Documented in the client README. **Implemented** as a form. The submit handler calls `POST /api/auth/login` with the registration form, not a create-user API.
- **Evidence:** `client/pages/registration.vue` `submitForm`. The `is-not-logged-in` middleware on that page is commented out.
- **Confidence:** High that the page does not create an account.

### FR-009 — Confirm an emailed link

The product tells the recipient to open a confirmation link that is valid for one day.

- **Provenance:** Documented in `registration.text.ejs` and `reminder.text.ejs`. **Not implemented** in the confirm handler or the confirm page. Token expiry is not checked.
- **Evidence:** `auth.controller.ts` `confirmRegistration` returns immediately. The confirm page has no `fetch`.
- **Confidence:** High.

### FR-010 — Reset a forgotten password

The reminder screen asks for a username and shows reset instructions.

- **Provenance:** The screen and the redirect are Implemented. Sending mail and changing the password are **not implemented** in `AuthController.reset`.
- **Evidence:** `auth.controller.ts` `reset`, `reminder.vue`, `reminder.text.ejs`.
- **Confidence:** High.

### FR-011 — Member group membership requests

A signed-in user can see groups, request to join, see pending requests, and cancel a pending request.

- **Provenance:** Implemented in `UsersService` (`joinGroup`, `pendingGroups`, `cancelPendingGroup`) and the user controller. Role checks on these routes are commented out.
- **Evidence:** `users.controller.ts`, `client/pages/member/groups-and-roles.vue`.
- **Confidence:** High for the API. The approval of a pending row by an administrator was not found as a dedicated "approve pending" handler. `admin.service.ts` `addGroupMembership` writes `users_has_group` directly.

### FR-012 — Administrator-only staff tools

Staff administration requires the role `IPW Administrator` on the API.

- **Provenance:** Implemented by `@Roles(ERole.IPWAdministrator)` on the controller class.
- **Client mismatch:** `client/middleware/isAdministrator.js` allows `System Developer` or `IPW Administrator`. A System Developer who is not also IPW Administrator can open the admin pages and then receive a failed API call.
- **User class:** `@UserClasses(EUserClass.IPWStaff)` is set on the admin controller. `users.guard.ts` knows how to read that metadata, and `app.module.ts` does not register that guard. The class check does not run.
- **Evidence:** `admin.controller.ts`, `app.module.ts`, `isAdministrator.js`.
- **Confidence:** High.

### FR-013 — Delete a user

`DELETE /api/user/:id` deletes the `users` row.

- **Who:** role `System Developer` or `IPW Administrator`.
- **Provenance:** Implemented. Related rows (`user_password`, tokens, group links) are not deleted in this method.
- **Evidence:** `users.controller.ts` `remove`, `users.service.ts` `remove`.
- **Confidence:** High.

### FR-014 — Member control-product report

A caller with one of the listed roles can filter control products by year, group, and product, and download a CSV.

- **Roles:** 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.
- **Data rule:** only `group_eval_form.status` in `(50, 55)`. Sentinel `-1` means "all" for year, group, and product.
- **Provenance:** Implemented. The status codes are not named in a comment or enum. What 50 and 55 mean in the legacy scheme is **Unknown / Requires Confirmation**. `ipw-php` may define them; this repository does not.
- **Evidence:** `reports.controller.ts`, `reports.service.ts`.
- **Confidence:** High for the query. Low for the business meaning of the status numbers.

### FR-015 — Upload member documents

Selected roles can list categories, upload a file, list documents, and download a file.

- **Roles:** IPW Administrator, IPW Auditor, IPW Lead Auditor, Member Administrator, Document Uploads. Listing "all profiles" and "all documents" is limited to the three IPW staff-side roles and does not include Member Administrator or Document Uploads.
- **Categories:** `document_category.category_type` is `Farm` or `Cellar` in the SQL notes. Hidden defaults to false.
- **Provenance:** Implemented. Category rows are notes in `ipw table adjustments.sql`, not a seed script.
- **Evidence:** `upload.controller.ts`, `upload.service.ts`, the SQL file.
- **Confidence:** High.

### FR-016 — Email queue

Registration emails are inserted into `email` and sent later by a scheduled job.

- **Schedule:** cron `*/1 * * * *` when `NODE_ENV` is `staging` or `production`. Otherwise `5 * * * * *` (second 5 of every minute).
- **Lock:** `cronjob` row id 1, column `processing`. On boot, if processing is true, it is set back to false, then the job starts.
- **Provenance:** Implemented. The reminder template is not queued by any controller.
- **Evidence:** `email.service.ts`, `main.ts`.
- **Failure:** SMTP verify is commented out. Send errors are logged. The exact retry rule is whatever `sendScheduled` does with `processed`; a failed send that still marks `processed` true was not fully traced. Treat retry behaviour as **Requires Confirmation** if operations depend on it.
- **Confidence:** Medium.

### FR-017 — Serve the client from the API process

In development the API serves `client/.output/static`. Otherwise it serves `static` next to the compiled server.

- **Provenance:** Implemented in `app.module.ts` `ServeStaticModule`.
- **Evidence:** `pathToStaticAssets`.
- **Confidence:** High. Whether any environment actually builds that folder is **Unknown**.

### FR-018 — Evaluation, probation, certificate, and SAWIS administration screens

The sidebar links to these areas for a logged-in administrator.

- **Provenance:** Implemented as Vue pages and middleware. **Not implemented** as server behaviour in this repository. Legacy tables are mapped in Prisma.
- **Evidence:** `Sidebar.vue`, empty controllers, commented imports on `sawis-search.vue`, `sawis-compliance.vue`, `probation/current.vue`, `certificates.vue`, `add-group.vue`.
- **Confidence:** High that the API is absent. Whether those screens were left for a later port is **Unknown / Requires Confirmation**.

## Constraints

- Global API prefix is `api`.
- CORS origins come from `CLIENT_ORIGIN`, which must be JSON with an `origin` array. Template value allows `http://localhost:8080` and `http://127.0.0.1:8080`.
- Cookie domain is `DOMAIN`. `sameSite` is `strict`. `httpOnly` and `secure` on the base cookie options are true only when `NODE_ENV` is `staging` or `production`. `setClientCookies` then forces the signature cookie to `httpOnly: true` regardless.
- `users.password` is `Char(32)` in Prisma (MD5). New hashes live in `user_password.password` `VarChar(97)`. The README asks for the password field itself to become `varchar(97)`. The schema at this revision still has `Char(32)` on `users.password`.
- `users.service.ts` hard-codes `ipwGroupID = 1364` as the IPW staff group. Whether that id is correct in every database is **Requires Confirmation**.
- Client password rule (`useValidation.js`): OWASP test with `minLength` 4, `minPhraseLength` 4, and `minOptionalTestsToPass` 2. i18n strings for names say "less than 40 characters" in one file and the composable allows 255. Server DTOs do not enforce either.
- Node engine on the client is `^18.0.0`. The server `package.json` does not set `engines`.

## Error conditions observed in code

| Condition | Behaviour | Provenance |
|---|---|---|
| Bad login | 401 `INVALID_CREDENTIALS` | Implemented |
| Duplicate username on create | 400 `EXISTS_USERNAME` | Implemented |
| Missing role on a guarded route | `RolesGuard` returns false | Implemented |
| Authenticated route with no `req.user` | `RolesGuard` calls `process.exit(1)` | Implemented |
| Upload for a null group | empty data object, HTTP 200 | Implemented |
| Confirm token | empty 200 | Implemented as a no-op |
| Reset | 200 and a redirect, no mail | Implemented as a no-op |

## Unknown / Requires Confirmation

- Whether this repository or `ipw-php` is the application that should keep being developed.
- Whether `https://ipw.co.za` or `https://staging.ipw.co.za` still run this Nest/Nuxt build.
- The meaning of `group_eval_form.status` 50 and 55.
- Whether group id 1364 is the staff group in the SharePoint database.
- Whether the blank frontend recorded on 2025-05-23 is still true.
- The SMTP host that production was meant to use.
- Whether pending group requests have an approval step that lived only in the legacy PHP this schema was pulled from.
