# PRD — supplier.magus.events

Evidence date: 2026-10-01. Source: `git@bitbucket.org:overdrive/supplier.magus.events.git`.

Behaviour below is `master` at `ccfc664` (2020-01-10) unless a requirement says otherwise. `prod` (`5c382ec`, 2020-03-20) adds a trusted-site script on the login page. `qa` (`e0414fb`) uses database host `qadb.footprintapp.net`. Pipelines deploy `qa` and `prod`, not `master`.

There is no prior PRD in this repository. Silicon Ark documents later relatives in `supplier.sarugby.online/` and `track.mypass.zone/`. Those documents are not the requirements for this remote.

Secrets that are hardcoded (database password, SMTP password, a login bypass password) are not copied into this document.

Provenance labels: **Documented** (copy in emails, FAQ, or views), **Implemented**, **Inferred**, **Planned**, **Unknown**.

## Product purpose

A supplier company registers with SA Rugby, confirms its email, signs in, and asks for a vehicle permit to load in or load out at an event. The company maintains its profile and a staff list. An administrator outside this application is described as the person who reviews the permit. The supplier tracks status in the portal and downloads the permit after approval.

Provenance: **Implemented** in the controllers, and **Documented** in the registration email, the permit email, and `PermitsController::Questions()`.

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Supplier administrator | Registers the company. `Rol` is set to `Administrator` | `RegisterController::newcompanyAction` | Implemented |
| Signed-in company user | Uses the dashboard, company profile, staff, permits, and FAQ. Identity comes from `Zend_Auth` | `IndexController`, `CompanyController`, `UsersController`, `PermitsController` | Implemented |
| Staff member | Record on `Company_Members`. Optional `AllowLogin`. Role label is one of Administrator, Director, Management, Operations, Driver, Technician, General Staff | `UsersController::init` | Implemented as stored data. No per-role permission check was found |
| Event administrator | Reviews a permit, may change the date and time, approves or declines | Permit email body | Documented. No approve or decline action exists in this repository |
| Health and safety officer | Approves a health and safety file at the venue | FAQ | Documented. Not implemented here |
| Anonymous visitor | Opens `/login` and `/register` | Those controllers do not require a session | Implemented |

The FAQ says only one company administrator is allowed. The staff screen can still add more people, including another person with role label Administrator. Provenance: **Documented** versus **Implemented**. See business rules.

The registration email says an administrator reviews the company before the profile can be activated, and that this can take up to 48 hours. `validateemailAction` sets `AllowLogin = 1` and `Email_Confirmed = 1` as soon as the link is opened, and the page says the account is now active. Provenance: **Documented** versus **Implemented**.

## Main workflows

### W1 — Register a company

Trigger: visitor opens `/register` (`RegisterController::indexAction`). Layout is disabled.

The page loads B-BBEE levels (`BBEE_Level`), company types (`Accre_Company_Types`), and countries (`countries`).

Submit: POST `/register/newcompany` with `data[...]` (company) and `P_data[...]` (person).

Company fields in the view: LegalName, TradingName, RegistrationNumber, RegistrationDate, VATNumber, CompanyType, Country, BBEECompliance. Person fields: FirstName, Surname, IDNumber, Mobile, Email, password.

Server behaviour (`newcompanyAction`):

1. If `RegistrationNumber` already exists on `Companies_Profile`, respond JSON `OK = 2` and a message that the account already exists and the user should contact SARU.
2. If `IDNumber` or `Email` already exists on `Company_Members`, the same `OK = 2` response.
3. Otherwise insert the person: password stored as MD5, `Rol = Administrator`, `DateCreated` today, `IDType = 1` when Country is `SOUTH AFRICA` and `2` otherwise, `Ref` is a random hex string.
4. Insert the company: `RefNumber` random, `CreatedBy` and `AdminID` are the new member id, `Active = 1`, `CreatedDate` today.
5. Insert `Company_Events` row `CID`, `EID = 44`, `Status = 2`.
6. Store `CID` and a `Validate_Key` on the member.
7. Email a confirmation link to `http://supplier.sarugby.online/register/validateemail/id/{key}`.
8. Respond JSON `OK = 1` with a thank-you message. Any exception responds `OK = 0`.

The action always `exit`s. It does not render a view.

Provenance: **Implemented**. The 48-hour review in the email is **Documented** and is not enforced by this action.

### W2 — Confirm email

Trigger: GET `/register/validateemail/id/{key}`.

If the key matches `Company_Members.Validate_Key`, the row is updated: key cleared, `Email_Confirmed = 1`, `Email_Confirmed_Date` today, `AllowLogin = 1`. The page says the account is active and links to `/login`.

If the key is missing, redirect to `/login/`. If the key matches no row, the page says the account may already be activated or the link does not exist.

Provenance: **Implemented**.

### W3 — Sign in

Trigger: GET or POST `/login` (`LoginController::indexAction`). Layout is disabled.

If `Zend_Auth` already has an identity, redirect to `/`.

POST fields: `email`, `password`.

- If the password equals a hard-coded bypass value, the code loads the stored password for that email from view or table `CompaniesLogin` and uses that stored value as the credential.
- Otherwise the posted password is MD5-hashed.
- Authentication is `Zend_Auth_Adapter_DbTable` on table `CompaniesLogin`, identity column `Email`, credential column `password`.
- On success: session namespace `EVENT`, `EID = 61`, identity written to auth storage, `Companies_Profile.LastLogin` updated with `MID = '{identity MID}'`, redirect to `/`.
- On failure: the page shows "Bad entered credentials".
- The action echoes the literal text `LOGIN` before the redirect (`LoginNow`).

Registration writes `Company_Members`. Login reads `CompaniesLogin`. This repository contains no insert into `CompaniesLogin`. Whether a database view or trigger makes a new registrant able to sign in is **Unknown / Requires Confirmation**.

`Companies_Profile::updateData` updates by `CID`, while `LoginNow` passes a `MID = '...'` string as the id argument. The last-login write may not update the intended row. Provenance: **Inferred** from the method signatures. Not executed against a database during this review.

Logout (`/login/logout`) clears the auth identity, destroys the session, and redirects to `/login`.

Provenance: **Implemented**.

### W4 — Reset a forgotten password

Trigger: POST `/login/forgot` with `Email`.

If a `Company_Members` row matches that email, a `ResetKey` (MD5 of 4 random bytes, upper-case hex) is saved and an email is sent. The link is `http://{current host}/login/reset/user/{key}`.

`/login/reset` requires `user`. If no row has that `ResetKey`, the view is assigned `SENT = 2`. On POST, the new password is stored as MD5, `ResetKey` is cleared, `AllowLogin = 1`, `Email_Confirmed = 1`, and a second email contains the new password in clear text.

The greeting in both emails uses `FirstName` twice. `Surname` is not used in the greeting. Provenance: **Implemented**.

If the email is not found, the action does not set an error message. Provenance: **Implemented**.

### W5 — Dashboard

Trigger: GET `/` after login. `IndexController::init` redirects to `/login/` when there is no auth identity.

The page assigns:

- Count of `Company_Members` for the identity's `CID` (`Total_Users`).
- Count of `Accreditation_Permits` for that `CID`. `getCount` accepts only `CID`. The call also passes the session event id; PHP ignores the extra argument, so the count is not limited to the event. Provenance: **Implemented**.
- Count of `Company_Events` rows for the company (`Total_Events`).
- Permits from view `Permits_view` where `CID` and `EID` match, ordered by `RequestDate`. The view groups them by `LoadType` and by the request-date timestamp.
- Accreditation row `Accreditation_List` where `AC` equals the session event id, and loading zones where `AID` equals that id.
- A list of future load-in and load-out dates taken from the latest permit's `LoadinActiveDate` and `LoadOutActiveDate` JSON, excluding dates whose cutoff (noon on the previous day) has passed. See business rules. The view variable is named `LoadOutArray` and contains both load-in and load-out dates.

Provenance: **Implemented**.

### W6 — Company profile

`/company/profile` requires login. GET loads `Companies_Profile` for the identity `CID`, plus B-BBEE levels, countries, and company types.

POST `data[...]` updates that company. JSON `OK = 1` when `updateData` does not return the same id it was given; `OK = 0` when it does. `updateData` returns the id on success and `false` on exception, so a successful save returns `OK = 0`. Provenance: **Implemented**. The meaning of `OK` is inverted relative to the registration action. **Inferred** that the front end treats `0` as success on this screen. The view script was not fully traced.

`/company/upload` accepts a data-URL image, writes `{CID}_image.jpg` under `public/archives/companies/`, and sets `Companies_Profile.Pic`.

`/login/companypic/id/{id}` returns the company image, or a bundled fallback, as `image/gif` even when the file is JPEG.

`/login/uploadremotecompany` updates a company picture from `cid` and `img` without checking `Zend_Auth`. Provenance: **Implemented**.

### W7 — Staff

`/users` lists `Company_Members` for the signed-in company.

`/users/add` POST `data[...]`. If `AllowLogin` is set, a random password is generated, MD5-stored, and not emailed by this action. `Ref`, `CID`, and `DateCreated` are set. An optional image is stored as `{MID}_image.jpg` under `public/archives/accreditation/`. JSON `OK = 0` when the insert id is greater than 0.

`/users/profile/id/{id}` loads that member. The query is not limited to the signed-in `CID`. POST updates the row. A new password is generated only when `AllowLogin` is turned on and the stored `AllowLogin` was 0. The new password is not emailed. If `AllowLogin` is absent, it is saved as 0.

Provenance: **Implemented**. Whether the profile id is authorised to the company is **not enforced** in this controller.

### W8 — Request a permit

`/permits/create` requires login. It loads the accreditation row for the session event, all rows from `Accreditation_Load_Services_Types` and `vehicletypes`, and the future load-in and load-out dates from that accreditation row. A date is offered only when it is after today and the cutoff (noon the day before) has not passed.

POST `/permits/addpermit` with `data`, `driver`, and `Accupant`:

- Occupants with a non-empty name are kept. Type `1` is treated as a permanent member: if `ID` matches `Company_Members.IDNumber`, the stored name and id replace the posted values and `MID` is set. Other occupants get `MID = 0` and `Driver = 0`.
- A driver with a name is appended with `Driver = 1`, and is also matched to `Company_Members` by ID number when found.
- The list is stored as JSON in `Accupants`.
- `Status = 2`, `EID` is the session event, `CID` and `MID` come from the identity, `DateCreated` is now, `PermitRefSerial` is a random 13-digit string.
- After insert, `PermitNr` is `{accreditation prefix}{PID padded to 6 digits}` and written back.
- An email is sent to the address on the permit view row. It states the request will be reviewed, quotes the permit number, says cancellations and changes are accepted until 1pm, and says administrators may change the date and time. Load-out is selected only when the view row's `Surname` field equals `2`. Otherwise the email says Load-In. Provenance: **Implemented**. Using `Surname` for the load type is **Inferred** to be a defect. Requires confirmation.
- The alternate mailer `postEmailToFp` is not called.

`/permits/profile/key/{id}` loads the permit only when `PID` and the identity `CID` match. Missing permit redirects to `/`. The form is locked when status is greater than 4, or when 13:00 on the day before `RequestDate` has passed. See business rules.

POST `/permits/savepermit` updates occupants the same way and sets `ApplicantModifiedDate` and `ApplicantModifiedBy`. It does not re-check the lock flag and does not check that the permit belongs to the company.

Provenance: **Implemented**.

### W9 — Duplicate or cancel

POST `/permits/duplicatepermit` copies the permit row, drops approval, decline, cancel, gate, and zone fields, sets `Status = 2`, a new `PermitRefSerial`, the current `EID`, `CID`, and `MID`, and sets `RequestDate` from the `date` parameter by swapping the first two slash-separated parts. `YourRefNumber` comes from `ref`. `PermitNr` is regenerated.

The copy is loaded with `getPermitData`, which filters by `PID` only, not by company.

POST `/permits/cancel` sets `Status = 5`, `CancelledBy` to the identity `MID`, `CancelledDate` now, and `CancelMessage` from `message`. It updates by `PID` only. A comment says an email should be sent. No email is sent. The action `print_r`s the data array.

Provenance: **Implemented**.

### W10 — Download the permit PDF

GET `/index/getpermitpdf/id/{id}` requires login (the controller `init` does). It fetches `https://www.footprintapp.net/pdf/getpermit/id/{id}`, converts encoding, renders A4 landscape with dompdf, writes `permit_{id}.pdf` under `AC_PATH/loadin/pdf/`, and streams the file.

`AC_PATH` is not defined in this repository. Provenance: **Implemented** call, **Unknown** whether the constant exists in the server environment.

The id is not checked against the signed-in company before the remote fetch.

### W11 — FAQ

`/permits/questions` requires login and renders the question list returned by `PermitsController::Questions()`. The answers are fixed strings. They name the HSBC World Rugby Sevens Series, delivery versus work permits, load-in versus load-out, a noon-or-1pm operational practice, and the contacts `HealthandSafety7s@sarugby.co.za` and `security@sarugby.co.za`. One answer says "Danie xxxxxx".

Provenance: **Documented** in code as static copy. Not a content-managed FAQ.

## Functional requirements

### FR-001 — Company registration

The system accepts a new company and its first user when the registration number is new and the ID number and email are new. It stores an MD5 password, marks the user Administrator, creates the company as active, links the company to event 44 with status 2, and emails a confirmation link on `supplier.sarugby.online`.

Actor: anonymous visitor. Trigger: POST `/register/newcompany`. Output: JSON `OK` 1, 2, or 0.

Provenance: **Implemented**. Confidence: High.

### FR-002 — Email confirmation activates login

Opening a valid confirmation key clears the key and sets `Email_Confirmed` and `AllowLogin`. An unknown key shows an error. A missing key redirects to login.

Actor: the person who received the email. Trigger: GET `/register/validateemail/id/{key}`.

Provenance: **Implemented**. The email's 48-hour administrator review is **Documented** and is not this behaviour. Confidence: High.

### FR-003 — Sign in against CompaniesLogin

A posted email and MD5 password are checked against `CompaniesLogin`. Success stores event 61 and redirects home. Failure shows "Bad entered credentials". An already-authenticated user is redirected home. A hard-coded password bypass substitutes the stored credential for any matching email.

Actor: a person with a `CompaniesLogin` row. Trigger: POST `/login`.

Provenance: **Implemented**. Whether registrants are present in `CompaniesLogin` is **Unknown**. Confidence: High for the code path, low for end-to-end registration-to-login.

### FR-004 — Password reset

A known email receives a reset link. Submitting a new password stores the MD5, clears the key, allows login, confirms the email, and emails the new password in clear text.

Actor: company member. Trigger: `/login/forgot` then `/login/reset/user/{key}`.

Provenance: **Implemented**. Confidence: High.

### FR-005 — Dashboard for the session event

A signed-in user sees staff count, permit count, event-link count, and permits for their company and session event, grouped by load type and request date, plus the accreditation profile and loading zones for that event.

Actor: signed-in user. Trigger: GET `/`.

Provenance: **Implemented**. The permit count ignores the event id. Confidence: High.

### FR-006 — Maintain the company profile and logo

The signed-in user's company can be updated. A logo can be uploaded to `public/archives/companies/{CID}_image.jpg`.

Actor: signed-in user. Trigger: `/company/profile`, `/company/upload`.

Provenance: **Implemented**. Confidence: High.

### FR-007 — Maintain staff

The company can list, add, and edit members. Roles are a fixed label list. Turning on login generates an MD5 password that this action does not email. Photos are stored under `public/archives/accreditation/`.

Actor: signed-in user. Trigger: `/users`, `/users/add`, `/users/profile/id/{id}`.

Provenance: **Implemented**. The profile is not scoped to the company. Confidence: High.

### FR-008 — Create a permit request

A signed-in user can submit a permit for the session event with vehicle and occupant details. New permits are status 2. The permit number is the accreditation prefix plus the id padded to 6 digits. An email is sent describing administrator review and a 1pm cutoff.

Actor: signed-in user. Trigger: POST `/permits/addpermit`.

Provenance: **Implemented**. Confidence: High. The Load-In versus Load-Out choice in the email is **Inferred** to be wrong when it reads `Surname`.

### FR-009 — Edit, duplicate, and cancel

The owner can open a permit. The form locks after status > 4 or after 13:00 on the day before the request date. Save, duplicate, and cancel actions exist. Cancel sets status 5. Duplicate creates a new status-2 permit.

Actor: signed-in user. Trigger: `/permits/profile`, `/permits/savepermit`, `/permits/duplicatepermit`, `/permits/cancel`.

Provenance: **Implemented**. Save, duplicate, and cancel do not enforce company ownership. Confidence: High.

### FR-010 — Permit PDF

A signed-in user can request a PDF. The HTML is loaded from footprintapp.net and rendered with dompdf.

Actor: signed-in user. Trigger: `/index/getpermitpdf/id/{id}`.

Provenance: **Implemented**, with `AC_PATH` **Unknown**. Confidence: Medium.

### FR-011 — FAQ

A signed-in user can read the fixed supplier FAQ.

Actor: signed-in user. Trigger: `/permits/questions`.

Provenance: **Documented** copy, **Implemented** page. Confidence: High.

### FR-012 — Deploy qa and prod

A pipeline on branch `qa` or `prod` zips the tree, uploads it to S3, and creates an AWS CodeDeploy deployment. The appspec installs under `/mnt/data/www/html/sarugby.online/supplier.sarugby.online/` as `apache`, installs `httpd`, and starts it.

Actor: a push to `qa` or `prod`. Provenance: **Implemented** in pipeline and appspec files. Whether those AWS variables still exist is **Unknown**.

## Integrations

| System | Direction | Trigger | Data | Auth | Failure |
|---|---|---|---|---|---|
| MySQL `teamwizz_SARU` | Read/write | Every page that uses a model | Companies, members, permits, accreditation, reference lists | Username and password in `application.ini` | Exceptions are often printed or swallowed. No user-facing database error contract |
| SMTP `mail.sarugby.co.za:25` | Outbound | Registration, reset, permit create | HTML email from `accreditation@sarugby.co.za` | Mailbox credentials in the controller | Exceptions are caught and not shown, except `PermitsController::sendMail`, which prints the exception |
| footprintapp.net PDF | Outbound HTTP GET | Permit PDF action | Permit id in the URL. HTML returned | None in this code | Exception is printed |
| footprintapp.net email | Outbound HTTP POST | Not called | Email, name, HTML | None in this code | Would print the exception |
| AWS S3 and CodeDeploy | Outbound | Pipeline on `qa` or `prod` | Zip of the working tree | Environment variables in Bitbucket | Script exits 1 on upload or deploy failure |
| `cdn.ywxi.net` | Browser script | Login page on `prod` only | Trust-seal script | None | Not handled |

## Important constraints

- Timezone is `Africa/Johannesburg`.
- Session cookie lifetime in `application.ini` is `864000` seconds (10 days).
- Passwords that this application writes are MD5, not a slow hash.
- `mcrypt` is required for registration refs, reset keys, and generated staff passwords.
- `display_errors` is turned on in `public/index.php` and again in the controllers, while `application.ini` turns display_errors off for the PHP settings section. The controller calls run later. Provenance: **Implemented**.
- There is no CSRF token, no login lockout, and no captcha on the auth or registration actions.
- SQL predicates are built by concatenating request values.

## Empty or mismatched surfaces

These files exist and do not provide the behaviour their names suggest:

- `EventsController.php` declares class `Companies_EventsController`. Zend's default dispatcher looks for `EventsController`, so `/events` is not wired to `indexAction`. That action prints `Accreditation_List` rows from `getEvents(2)` and redirects unauthenticated users to `/companies/login/`, a route with no controller in this module.
- `OrgController.php` declares class `Company_OrgController`. Its actions are empty.
- `OrganisationController::profileAction` is empty. The layout links Company to `/company/profile`, not `/organisation/profile`.
- `AuthonController` actions are empty.
- `IndexController::newcompanyAction` is a second registration path that does not hash the password and points the validation link at `http://www.sarugby.online/company/index/validateemail/id/{member id}`. `validateemailAction` on `IndexController` is empty. This path is not linked from the public registration view.
- `menu/main_menu.phtml` is a Limitless demo menu (Employees, Events) with `href="#"`. The live chrome is `layouts/default.phtml`.
- Root `index.html` is the Plesk default page ("the website is not available"). The supplier application is reached through the rewrite into `public/`.

Provenance: **Implemented**.

## Requires confirmation

- Is `prod` (`5c382ec`) the revision that was served at `supplier.sarugby.online`, or was traffic on `master`?
- Does a database object named `CompaniesLogin` include rows created by registration?
- What do permit status ids other than the ones this UI labels mean? The UI treats `< 4` as Pending, `4` as Approved, `> 4` and `5` as Cancelled. New rows are stored as `2`. The `Permit_Status` table was not read.
- What does `Company_Events.Status = 2` mean?
- Are event ids `44` (registration) and `61` (login session) intentional, and which event should suppliers see?
- Is `AC_PATH` defined on the server?
- Are the plaintext credentials still valid, and have they been rotated?
- Is the 27 MB `default.zip` a deploy artifact that should remain in git?
