# PRD — supplier.sarugby.online

Evidence date: 2026-10-01. Source: `git@bitbucket.org:overdrive/supplier.sarugby.online.git`, `master` at `6f0e6c2` (2021-04-29).

There is no prior PRD in the repository or in Silicon Ark for this directory. Every requirement below was recovered from the code. Provenance is marked on each item.

Related Silicon Ark: `track.mypass.zone/` documents a later fork used as an operations dashboard. Behaviour that differs is called out where it matters. This PRD is only for `supplier.sarugby.online`.

## Product purpose

Suppliers and service providers request vehicle load-in and load-out permits for an SA Rugby event. They register a company, confirm an email address, maintain a company profile and a staff list, and submit permit requests that an administrator reviews outside this application.

The page title and the emails name SA Rugby Event Administration. The FAQ text names the HSBC World Rugby Sevens Series. Provenance: **Documented** in `application/layouts/default.phtml` and `PermitsController::Questions()`. Whether that series is still the event this build serves is **Unknown / Requires Confirmation**.

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Anonymous visitor | Opens `/login` or `/register`. Cannot open the dashboard, permits, company, or staff actions. | `init()` on Index, Permits, Company, Users redirects to `/login/` when `Zend_Auth` has no identity | Implemented |
| Registering company administrator | Submits company and personal details. Receives a validation email. After clicking the link, may log in. | `RegisterController`, `register/index.phtml` | Implemented |
| Signed-in company user | Uses dashboard, company profile, staff, FAQ, and permits. Identity row comes from view `CompaniesLogin`. | `LoginController::getAuthAdapter` | Implemented |
| Staff member | A row in `Company_Members` for the company. May be flagged `AllowLogin`. Role label is one of Administrator, Director, Management, Operations, Driver, Technician, General Staff. | `UsersController` | Implemented |
| Event administrator | Approves, declines, or changes a request. Not a login in this repository. Emails say an administrator will review the request. | Permit email body; statuses 4 and 6 are displayed and never written here | Documented in email and FAQ. The actor's system is Unknown |
| SA Rugby contact centre | Named in email footers as +27 21 928 7000 and `accreditation@sarugby.co.za`. Load-in enquiries also name `permits@sarugby.co.za`. Health and safety and security mailboxes are in the FAQ. | Email HTML and `Questions()` | Documented |

The first registered user is stored with `Rol` = `Administrator`. The FAQ says only one administrator is allowed. The staff screens do not enforce that. See FR-014.

## Main workflows

### W1 — Register a company

Trigger: visitor opens `/register` and posts `/register/newcompany`.

Inputs (`register/index.phtml` posts `P_data` for the person and `data` for the company). The controller uses registration number, country, trading name, ID number, email, password, first name, and surname. Other posted company fields are passed through to `Companies_Profile` without a server-side allow-list.

Server behaviour (`RegisterController::newcompanyAction`):

1. If `Companies_Profile` already has that `RegistrationNumber`, return JSON `OK` 2 and a message that the account already exists and the user should contact SARU.
2. If `Company_Members` already has that `IDNumber` or `Email`, return the same `OK` 2 message.
3. Otherwise insert the member with `password` = MD5 of the submitted password, `Rol` = `Administrator`, `DateCreated` today, `Ref` from 3 random bytes, and `IDType` 1 when country is `SOUTH AFRICA`, else `IDType` 2.
4. Insert the company with `Active` = 1, `AdminID` and `CreatedBy` = the new member id, `RefNumber` from 6 random bytes, `CreatedDate` today.
5. Insert `Company_Events` with `CID`, `EID` = 44, `Status` = 2.
6. Store a `Validate_Key` (8 random bytes, hex, upper case) on the member.
7. Email a link `http://supplier.sarugby.online/register/validateemail/id/{key}`. The body says an administrator will review the company before the profile can be activated, and that activation can take up to 48 hours. It also says the user must click the link to verify the email, and that without additional documents they cannot sign up and connect to an event.
8. Return JSON `OK` 1 and thank-you HTML. Exceptions return `OK` 0.

Provenance: **Implemented**. The 48-hour review sentence is **Documented** in the email string. The insert sets `Companies_Profile.Active` = 1 immediately, and nothing in this repository waits 48 hours. **Requires Confirmation** which of those is the intended rule.

`IndexController::newcompanyAction` is a second, older registration path. Its validation link points at `http://www.sarugby.online/company/index/validateemail/id/{member id}`. No view in this repository posts to that action. `AuthonController::newcompanyAction` is empty. `authon/register.phtml` posts to `/company/authon/newcompany`, which does not match `AuthonController`. Provenance: **Implemented** dead or mismatched paths.

### W2 — Confirm email

Trigger: the user opens `/register/validateemail/id/{Validate_Key}`.

If the key matches `Company_Members.Validate_Key`, the row is updated: `Validate_Key` cleared, `Email_Confirmed` = 1, `Email_Confirmed_Date` today, `AllowLogin` = 1. The page says the account is now active and links to `/login`. A missing or already-used key shows an error and the same login link. A request with no id redirects to `/login/`.

Provenance: **Implemented**. Login does not read `AllowLogin` or `Email_Confirmed`. See FR-004.

### W3 — Sign in and sign out

Trigger: POST `/login` with `email` and `password`.

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

If the submitted password equals a hardcoded string in `LoginController::indexAction`, the action loads the stored password for that email from `CompaniesLogin` via `Model_CompanyProfile::adminLogin` and authenticates with that stored value. Otherwise it authenticates with MD5 of the submitted password.

`LoginNow` uses `Zend_Auth_Adapter_DbTable` on table or view `CompaniesLogin`, identity column `Email`, credential column `password`. There is no treatment (no extra hash) on the adapter, so the credential must already be the stored MD5.

On success:

- Session namespace `EVENT`, property `EID`, is set to `61`.
- The whole result row is written to the auth storage.
- `Model_CompanyProfile::updateData` is called with `LastLogin`. That method always updates `Companies_Profile` with `CID = '{second argument}'`. The caller passes the string `MID ='{id}'` as the second argument, so the predicate is not a company id. **Inferred:** this call does not record the login.
- Redirect to `/`.

On failure the login page shows "Bad entered credentials". `LoginNow` also echoes the text `LOGIN` before authenticating.

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

Provenance: **Implemented**. The hardcoded password is a security defect. The value is not copied into Silicon Ark.

### W4 — Reset a forgotten password

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

If `Company_Members` has that email, store `ResetKey` = MD5 of 4 random bytes and email an `http://` link `/login/reset/user/{key}`. The mail says to contact SA Rugby if the user did not request the reset. If the email is not found, the action sets no success flag. Provenance: **Implemented**. Whether the view tells the user the address was unknown was not fully traced; the controller only assigns `SENT` inside the success branch.

Trigger: POST `/login/reset/user/{key}` with `password`.

The key must match `Company_Members.ResetKey`. The update sets `ResetKey` empty, `password` = MD5 of the new password, `AllowLogin` = 1, `Email_Confirmed` = 1, and emails the new password in clear text. A missing key redirects to `/login/`. An unknown key assigns `SENT` = 2.

Provenance: **Implemented**. The reset mail addresses `$userD["FirstName"]` twice and never sets `$userD` in `resetAction` (the variable is set in `forgotAction` only). **Inferred** that the reset confirmation email can warn or fail on an undefined variable. The greeting in `forgotAction` also prints `FirstName` twice.

### W5 — Dashboard

Trigger: authenticated GET `/`.

`IndexController::init` redirects to `/login/` when there is no identity, then reads `EVENT` session `EID` and assigns `CID` and `EID` to the view.

The action loads:

- Staff total from `UserProfile::TotalCount` for the company.
- Permit rows from `AccreditationPermits::getCount` for the company. The call passes `EID` as a second argument. The method accepts only `CID`, so the event id is ignored. The view is given `count($permit)`.
- Event rows from `CompanyEvents::getEventCount` for the company.
- Permit rows for the dashboard from `Permits_view` where `CID` and `EID` match, ordered by `RequestDate`.
- The accreditation row `Accreditation_List` where `AC` equals the session event id.
- Loading zones where `AID` equals the session event id.

Permits are grouped in PHP by `LoadType`, then by the request-date timestamp. Future load-in and load-out dates from the last permit in that loop are built for the view, excluding dates that are not after today and excluding a date when the current time is already at or after 12:00 on the previous day.

Provenance: **Implemented**.

### W6 — Request a permit

Trigger: authenticated GET `/permits/create`, then POST `/permits/addpermit/`.

The create page offers Load-In (`LoadType` 1) and Load-Out (`LoadType` 2), a service type from `Accreditation_Load_Services_Types` excluding `SID` 3, a free-text reference, a date dropdown filled in the browser from the accreditation's active dates, time in, time out, job description, vehicle type from `vehicletypes`, and driver plus occupants.

`createAction` builds selectable dates from `Accreditations_view` (`getEvent`) JSON fields `LoadinActiveDate` and `LoadOutActiveDate`. Each stored date is `d/m/Y` split on `/`. A date is offered only when it is after today and the current time is still before 12:00 on the previous calendar day.

`addpermitAction` on POST:

- Builds an occupants list. Empty names are skipped. An occupant with `Type` 1 is matched to `Company_Members` by `IDNumber`; on a match the stored first name, surname, and ID replace the submitted values and `MID` is set.
- The driver, when `Name` is not empty, is matched the same way and stored with `Driver` = 1. Other occupants are stored with `Driver` = 0.
- Occupants are JSON in column `Accupants` (spelled that way in the column).
- `Status` is set to 2. `MID` and `CID` come from the identity. `EID` is the session event. `PermitRefSerial` is a random 13-digit zero-padded number. `DateCreated` is now.
- After insert, `PermitNr` is `{Accreditation_List.PreFix}` plus the new `PID` zero-padded to 6 digits, then the row is updated.
- An email is sent to the address on the permit row (`Permits_view`). The body includes load type, permit type, date, times, and permit number. Load-out is chosen when `$permit["Surname"] == 2`. **Inferred** defect: load type is `LoadType`, not `Surname`, so the email can say "Load-In" for a load-out request.
- The footprintapp.net email POST is commented out. SMTP `sendMail` is what runs.

The email states that cancellations and changes may be submitted up to 1pm daily, and that administrators may change the requested date and time. Provenance: **Documented** in the email. The create-page date filter uses 12:00, and the profile lock uses 13:00. See business rules.

### W7 — View, edit, duplicate, or cancel a permit

Trigger: dashboard links `/permits/profile/key/{PID}/id/{md5 of hour-day-minute-second}`. The `id` segment is not checked by the controller. The permit is loaded only when `PID` and the signed-in `CID` match `Permits_view`. A miss redirects to `/`.

Edit POST `/permits/savepermit/pid/{PID}` updates occupants the same way as create, plus `ApplicantModifiedDate` and `ApplicantModifiedBy`. It does not change `Status`.

The profile view disables inputs when the request is locked. Lock is true when now is at or after 13:00 on the calendar day before `RequestDate`. When the request is not locked, offered dates are those still after today and still before that 13:00 cutoff. When it is locked, the stored active dates are shown without that filter.

Cancel POST `/permits/cancel/id/{id}` with `message` sets `Status` = 5, `CancelledDate`, `CancelledBy` = the member id, and `CancelMessage`. It does not check that the permit belongs to the signed-in company. A comment says "email supplier for cancelling"; no email is sent. The action `print_r`s the update array.

Duplicate POST `/permits/duplicatepermit/id/{id}` copies `getPermitData` (the base table, not filtered by company), drops approval, decline, cancel, gate, and zone fields, sets `Status` = 2, a new 13-digit serial, `EID` from the session, `RequestDate` from posted `date` rearranged as `m/d/Y` from a `d/m/Y` split, `YourRefNumber` from posted `ref`, and new applicant stamps. `PermitNr` is regenerated from the new id.

Provenance: **Implemented**. Company scoping on cancel and duplicate is absent. **Inferred** that any signed-in user who knows a permit id can cancel or copy it.

### W8 — Download a permit PDF

Trigger: `IndexController::getpermitpdfAction` with `id`.

The action fetches `https://www.footprintapp.net/pdf/getpermit/id/{id}`, converts the HTML from UTF-8 to Windows-1250, renders landscape A4 with Dompdf, writes `permit_{id}.pdf` under `AC_PATH/loadin/pdf/`, and streams it. `AC_PATH` is not defined in `index.php` or `application.ini`. Provenance: **Implemented** call. Whether `AC_PATH` is defined in the server environment is **Unknown**. The action does not check that the id belongs to the signed-in company. It does require a login because `init()` does.

### W9 — Company profile and staff

Company profile GET/POST `/company/profile` loads and updates `Companies_Profile` for the identity `CID`. POST fields are whatever is under `data`, with no field allow-list. Success JSON is `OK` = 0, which `company/profile.phtml` treats as success. Logo upload POST `/company/upload` writes `{CID}_image.jpg` under `public/archives/companies/` from a base64 image and stores the file name in `Pic`.

`/company/getmembers` is an empty action.

Staff list `/users` lists `Company_Members` for the company. Add POST `/users/add` inserts a member with `CID` and `DateCreated`. If `AllowLogin` is posted, a 3-byte random password is MD5-hashed and stored. The password is not emailed. A base64 image is stored as `{MID}_image.jpg` under `public/archives/accreditation/`. Profile POST `/users/profile/id/{id}` updates that member. It does not check that the id belongs to the signed-in company. A new password is generated only when `AllowLogin` is posted and the stored `AllowLogin` was 0.

Provenance: **Implemented**.

### W10 — FAQ

GET `/permits/questions` renders the question list returned by `PermitsController::Questions()`. The copy is in the controller, not in the database. Provenance: **Documented** and **Implemented** as static text.

## Functional requirements

### FR-001 — Company registration

The visitor can register a company that is not already stored, together with a person whose ID number and email are not already stored. The person is the company administrator. The company is linked to event 44 with status 2.

Evidence: `RegisterController::newcompanyAction`, `register/index.phtml`.

Implementation: Implemented.

Confidence: High for the code path. The event id 44 versus login event 61 requires confirmation.

### FR-002 — Email confirmation

The registrant can confirm the email by opening the link that contains `Validate_Key`. Confirmation sets `AllowLogin` and `Email_Confirmed`.

Evidence: `RegisterController::validateemailAction`.

Implementation: Implemented.

Confidence: High.

### FR-003 — Login

A user whose email and MD5 password match `CompaniesLogin` receives an authenticated session and event id 61.

Evidence: `LoginController`.

Implementation: Implemented.

Confidence: High.

### FR-004 — Login does not check AllowLogin

`AllowLogin` and `Email_Confirmed` are written during confirmation and password reset. The authentication adapter does not filter on them.

Evidence: `getAuthAdapter`, `validateemailAction`.

Implementation: Implemented behaviour. Whether confirmation is required before login is **Requires Confirmation**, because the email says the account becomes active after the link is clicked, and the code allows a matching password to sign in without reading those flags.

Confidence: High that the flags are not checked. Medium that this is unintentional.

### FR-005 — Password storage and reset

Passwords are MD5, not a slow hash. Reset emails the new password in clear text over SMTP on port 25. The reset link is `http`, not `https`, even though the module bootstrap redirects non-443 traffic to HTTPS.

Evidence: `LoginController::forgotAction`, `resetAction`, `sendMail`.

Implementation: Implemented.

Confidence: High.

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

A signed-in user sees counts and the company's permits for the session event, grouped by load type.

Evidence: `IndexController::indexAction`, `index/index.phtml`.

Implementation: Implemented. The permit count query ignores the event id (FR-006a).

Confidence: High.

### FR-007 — Permit request

A signed-in user can submit a load-in or load-out request. The row is stored with status 2 and a permit number built from the accreditation prefix and the new id. An email is sent to the address on the permit view.

Evidence: `PermitsController::createAction`, `addpermitAction`, `create.phtml`.

Implementation: Implemented.

Confidence: High.

### FR-008 — One permit per day per vehicle, as stated to the user

The FAQ says a permit is for one day and one vehicle, and that a previous request can be duplicated onto another day. The create form has a single date field. The server does not reject a second permit for the same vehicle and date.

Evidence: `Questions()`, `addpermitAction`.

Implementation: Documented in the FAQ. Uniqueness is not implemented.

Confidence: High.

### FR-009 — Delivery duration

The FAQ says a delivery permit cannot be scheduled for longer than 2 hours, and that a work permit requires a health and safety file. The create form has free time-in and time-out fields. The server does not compare them. Service type `SID` 3 is omitted from the dropdown. What `SID` 3 means is **Unknown** without the database.

Evidence: `Questions()`, `create.phtml`.

Implementation: Documented only.

Confidence: High that the 2-hour rule is not enforced in this repository.

### FR-010 — Edit cutoff

The user can change a permit until a cutoff before the request date. The email says 1pm. The profile lock uses 13:00 on the previous day. The create and dashboard date lists use 12:00 on the previous day.

Evidence: `PermitsController::profileAction`, `createAction`, `IndexController::indexAction`, the permit email string.

Implementation: Implemented, with three different clocks. **Requires Confirmation** which cutoff is the business rule.

Confidence: High that the three clocks differ.

### FR-011 — Cancel and duplicate

The user can cancel a permit (status 5 plus a message) and can copy a permit onto another date as a new status-2 request.

Evidence: `cancelAction`, `duplicatepermitAction`, `index/index.phtml`.

Implementation: Implemented. Company ownership is not checked.

Confidence: High.

### FR-012 — Status display

The dashboard labels status as Pending when status is less than 4, Approved when 4, Cancelled when 5, Declined when 6, Checked-In when 7, Delayed when 8, Checked-Out when 9.

Evidence: `index/index.phtml`.

Implementation: Implemented for display. This repository sets only 2 (new or duplicated) and 5 (cancelled).

Confidence: High.

### FR-013 — Company profile

The signed-in user can update the company row and upload a logo. B-BBEE levels (`BBEE_Level`), company types (`Accre_Company_Types`), and countries (`countries`) are listed on the form.

Evidence: `CompanyController`, `company/profile.phtml`.

Implementation: Implemented.

Confidence: High.

### FR-014 — Staff

The signed-in user can add and edit staff for their company, including a photo and an optional login flag. The FAQ says only one administrator is allowed and tells the user to add temporary staff from the Staff menu. The code allows any of the seven role labels and does not count existing administrators.

Evidence: `UsersController`, `Questions()`.

Implementation: Staff editing is implemented. The one-administrator rule is documented in the FAQ and not implemented.

Confidence: High.

### FR-015 — FAQ

The signed-in user can read the in-code FAQ, including health-and-safety file contents, the difference between delivery and work permits, and the difference between load-in and load-out.

Evidence: `permits/questions`, `Questions()`.

Implementation: Implemented.

Confidence: High.

### FR-016 — Permit PDF

A signed-in user can request a PDF whose HTML is produced by footprintapp.net.

Evidence: `IndexController::getpermitpdfAction`.

Implementation: Implemented, depending on footprintapp.net and on `AC_PATH`.

Confidence: Medium. The constant and the ownership check are unresolved.

## Integrations

| System | Purpose | Trigger | Direction | Auth | Failure |
|---|---|---|---|---|---|
| MySQL `teamwizz_SARU` | All product data | Every action that uses a model | Read/write | Username and password in `application.ini` | Exceptions are often printed or swallowed. There is no user-facing retry. |
| SMTP `mail.sarugby.co.za:25` | Registration, reset, permit notification | Those actions | Outbound | Mailbox login in each `sendMail` | Exceptions are caught. Registration still returns success JSON if the send throws inside `sendMail`. Permit send failures can echo the exception. |
| footprintapp.net PDF | Permit document HTML | `getpermitpdfAction` | Outbound GET, then PDF response to the browser | None in the URL | The exception is printed. |
| footprintapp.net email | Alternate mail sender | `postEmailToFp` | Prepared POST | None in the snippet | Not called. The call site is commented out. |
| AWS CodeDeploy | Deploy | Push to `qa` or `prod` | Outbound | Bitbucket repository variables | The Python script prints the boto error and returns false. The pipeline image is `python:3.5.1`. |

## Constraints

- Timezone `Africa/Johannesburg` is set in `public/index.php`.
- Locale default `af_ZA` is set in `application.ini`. The UI strings that were read are English.
- Session cookie lifetime is 864000 seconds (10 days).
- Non-443 requests are redirected to HTTPS by `Default_Bootstrap`, if that module bootstrap runs. ZF1 module bootstraps run when the module is bootstrapped; whether this class runs on every request was not proven from configuration alone. Provenance: **Implemented** in the class. **Unknown** whether it is invoked, because `application.ini` does not register a module bootstrap resource by name. The class file sits in `controllers/Bootstrap.php`, which is the conventional module bootstrap path.
- `public/index.php` sets `display_errors` to 1. `application.ini` sets `display_errors` to 0 for every environment, including development. The front controller runs after the `ini_set`, so errors can still display. Provenance: **Implemented**.
- Queries concatenate request values into SQL. Provenance: **Implemented**. This is recorded as a defect, not as a procedure.

## Error conditions

| Condition | Behaviour | Evidence |
|---|---|---|
| Not signed in | Redirect to `/login/` for dashboard, permits, company, staff | Controller `init` methods |
| Registration duplicate company or person | JSON `OK` 2 | `RegisterController` |
| Registration exception | JSON `OK` 0 | `RegisterController` |
| Bad password | "Bad entered credentials" | `LoginController::indexAction` |
| Permit id not owned by the company on profile | Redirect to `/` | `profileAction` |
| Permit id not owned on cancel or duplicate | No ownership check | `cancelAction`, `duplicatepermitAction` |
| Uncaught view error | `ErrorController` distinguishes 404, denied, and application errors | `ErrorController` |

## Out of scope for this repository

- Approving or declining a permit.
- Checking a vehicle in or out at the gate.
- The operations PIN dashboard documented under `track.mypass.zone`.
- Defining the MySQL views.

## Open product questions

1. Which event id is production: 61, 44, or a value maintained outside this repository?
2. Is the 48-hour administrator review still required, given that `Active` is set to 1 on insert?
3. Must email confirmation block login?
4. Which cutoff is correct: 12:00 or 13:00 on the day before the permit date?
5. Should cancel and duplicate be limited to the signed-in company?
6. Is the FAQ's HSBC Sevens copy still the product, or has the event moved on (as `track.mypass.zone` later did)?
