# PRD — permits.magus.events

Evidence date: 2026-10-07. Source: `git@bitbucket.org:overdrive/permits.magus.events.git`, `master` at `4617ab1` (2022-06-01).

There is no prior PRD in the repository or in Silicon Ark for this remote. Every requirement below was recovered from the code, views, and commit history. Provenance is marked on each item.

Secrets that are hardcoded (database password, SMTP password, shared login secret, a sample identity number, a personal mailbox in a test action) are not copied into this document.

## Product purpose

The site lets a desk operator create a vehicle load-in or load-out permit for a company that is already linked to one event.

The login page asks for a PIN. A matching value stores event id `45` in the session and sends the browser to `/`, which immediately redirects to `/walkins`. Provenance: **Implemented** (`LoginController::indexAction`, `IndexController::init`).

A second purpose is present in the same code: companies register, confirm email, maintain a profile and staff, and an operations dashboard lists every permit for the event. The emails and FAQ describe that as SA Rugby accreditation for suppliers. The FAQ also names the HSBC World Rugby Sevens Series. Provenance: **Implemented** in `RegisterController`, `UsersController`, `CompanyController`, and `IndexController::indexAction`, and **Documented** in the email bodies and `PermitsController::Questions()`. The dashboard and the company-admin screens are not reachable from the PIN login. See FR-001.

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Desk operator | Enters the shared secret, picks a company, enters that company's passkey, and submits a permit | `LoginController::indexAction`, `WalkinsController`, `walkins/new.phtml` | Implemented |
| Supplier administrator | Registers a company, confirms email, would sign in with email and password | `RegisterController`, `LoginController::LoginNow` (not called by the login form) | Implemented, not reachable from the current login form |
| Company staff roles | Labels only: Administrator, Director, Management, Operations, Driver, Technician, General Staff | `UsersController::init` | Implemented as a dropdown. No permission checks per role were found |
| Permit applicant | Named on the permit and is the intended confirmation-email recipient | `PermitsController::addpermitAction` | Implemented as the email recipient. The walk-in form is filled by the desk operator |
| Event administrator | Approves, declines, or changes a request; modifies date and time to balance load | Email body in `addpermitAction` | Documented in email copy. No approve or decline action exists in this repository |
| Health and safety officer | Approves a health and safety file at the venue | FAQ answers | Documented. Not implemented in this repository |
| Anonymous visitor | Can open `/login` and `/register` | Those controllers do not require a session | Implemented |

`RegisterController` sets the new user's `Rol` to `Administrator`. The FAQ says only one administrator is allowed. `UsersController` can still insert further people, but only after a `Zend_Auth` identity exists, which the PIN login does not create. Provenance: **Documented** versus **Implemented**. See business rules.

## Main workflows

### W1 — Open the walk-in desk

Trigger: visitor opens `/login`, or opens `/` or any `WalkinsController` / `PermitsController` action without a session.

Gate (`WalkinsController::init`, `PermitsController::init`, `IndexController::init`): if session namespace `EVENT` has no `EID`, redirect to `/login/`.

Login (`LoginController::indexAction`):

- Layout is disabled.
- If `EVENT.EID` is already greater than 0, redirect to `/`.
- POST field `password`. The view heading is "Please enter you PIN Number" (wording as written). The input placeholder is "Password".
- If the value equals the hardcoded secret, set `EVENT.EID = 45` and redirect to `/`.
- Otherwise the page shows "Bad entered credentials".
- There is no username field, no lockout, and no captcha.

`IndexController::init` then redirects to `/walkins` whenever `EID` is set, and to `/login/` when it is not. Both branches redirect. The dashboard code in `indexAction` does not run.

`WalkinsController::indexAction` loads companies for that event through `Companies_Profile` joined to `Company_Events` and the admin `Company_Members` row (`AdminID`). The page heading is "SELECT YOUR COMPANY". Each card shows trading name, company type, and a "New Permit" button carrying `CEID` and `passkey`.

Search types into `/walkins/search` and replaces the card list. Layout is disabled for that action. The search string is concatenated into the SQL `LIKE` clause.

Provenance: **Implemented**.

### W2 — Open the new-permit form

Trigger: "New Permit" opens a modal titled "PIN CODE". The browser compares the typed value with the `passkey` already rendered on the button. A match navigates to `/walkins/new/id/{CEID}/ref/{passkey}`. A mismatch alerts "Please enter your correct pincode!" and stays on the page.

`WalkinsController::newAction`:

- Missing or non-positive `id` redirects to `/`.
- `CompanyEvents::getRow` loads the company only when `CEID` and `passkey` both match. No row redirects to `/walkins`.
- The form is filled with the event row (`Accreditation_List` via `getEvent`), loading-service types, vehicle types, and load-in / load-out dates.
- Dates stored as `d/m/Y` JSON (`LoadinActiveDate`, `LoadOutActiveDate`) are kept only when they are today or later in `Africa/Johannesburg`.

Provenance: **Implemented**. The modal check is entirely in the browser. The page load repeats the check on the server.

### W3 — Submit a permit

Trigger: the walk-in form posts to `/permits/addpermit/cid/{CID}`.

`PermitsController::addpermitAction` runs the insert only for POST:

1. Occupants with a non-empty name are kept. Type `1` (Permanent Staff) looks up `Company_Members` by the submitted ID number and, on a hit, replaces name, surname, and ID with the stored member. `Driver` is set to `0`.
2. A driver with a non-empty name is looked up the same way and stored with `Driver = 1`.
3. Occupants and driver are JSON-encoded into `Accupants`.
4. `PermitNr` is first taken from `getRef` (accreditation prefix plus a count). The insert then overwrites `PermitNr` with `PM` plus the new `PID` zero-padded to 6 digits. The stored number is the `PM` form.
5. `PermitRefSerial` is 13 random digits. `Status` is `2`. `EID` is the session event. `CID` is the URL parameter, not a session identity. `DateCreated` is now.
6. The action then loads the permit with `getPermit($id, $this->identity->CID)` and emails the address on that row.

`PermitsController::init` never assigns `$this->identity`. The email lookup therefore reads a property on null. Provenance: **Implemented**. Whether the insert is committed before that error is **Inferred** from the statement order (insert, then update of `PermitNr`, then the identity read). A runtime confirmation is still required.

The email, when it can be built, tells the recipient the request will be reviewed, quotes the permit number as `{PermitNr}/{year}`, says cancellations and changes are accepted until 1pm, and lists a 14-point health and safety file for work permits. Sign-off is "SA Rugby Event Administration". Contact is `accreditation@sarugby.co.za` and phone `+27219287000`.

The load-type sentence treats `$permit["Surname"] == 2` as load-out. That comparison is against the surname column. Provenance: **Implemented** as written. Whether load-out mail is ever labelled correctly is **Unknown / Requires Confirmation**.

`postEmailToFp` (POST to `https://footprintapp.net/email/send`) is defined. The call is commented out. Mail uses SMTP.

A second form, `permits/create.phtml`, posts to `/permits/addpermit/` without a `cid` path segment. That screen is not linked from the walk-in flow.

Provenance: **Implemented** and **Documented** (email body).

### W4 — Register a supplier company

Trigger: POST `/register/newcompany` with `P_data` (person) and `data` (company). GET `/register` does not require the event session. Layout is disabled. The page loads B-BBEE levels, accreditation company types, and countries.

Server order (`RegisterController::newcompanyAction`):

1. If `Companies_Profile.RegistrationNumber` already exists, JSON `OK=2` and a message to contact SARU.
2. If `Company_Members` already has the ID number or the email, same `OK=2` message.
3. Otherwise insert the person: password stored as `md5`, `Rol = Administrator`, `DateCreated` today, `IDType` 1 when company country is exactly `SOUTH AFRICA`, otherwise 2, `Ref` is 3 random bytes as uppercase hex.
4. Insert the company: `RefNumber` is 6 random bytes as hex, `CreatedBy` and `AdminID` are the new member id, `CreatedDate` today, `Active = 1`.
5. Insert `Company_Events` with `CID`, `EID = 44`, `Status = 2`. No `passkey` is written.
6. Store a validate key on the member and email a link to `http://supplier.sarugby.online/register/validateemail/id/{key}`.
7. JSON `OK=1` says an email was sent.

The email says an administrator will review the company and that activation can take up to 48 hours. It also says the person should click the link to verify the email. Provenance: **Documented** in the email. No review queue exists in this repository.

`validateemailAction` on this host clears `Validate_Key`, sets `Email_Confirmed = 1`, `Email_Confirmed_Date` to today, and `AllowLogin = 1` when the key matches. The registration email does not point at this host.

Provenance: **Implemented**. The event id `44` was not updated when the PIN session was set to `45`. A company that registers here is not linked to the event the desk lists.

### W5 — Reset a password

`/login/forgot` looks up `Company_Members` by email, stores `ResetKey` as `md5` of 4 random bytes, and emails `http://{server}/login/reset/user/{key}`.

`/login/reset/user/{key}` requires the key. POST stores `md5` of the new password, clears `ResetKey`, and sets `AllowLogin = 1` and `Email_Confirmed = 1`. The confirmation email includes the new password in clear text. The greeting uses `FirstName` twice; `Surname` is not used. `$userD` is not set in `resetAction`, so the greeting name is empty unless a notice is suppressed.

Provenance: **Implemented**. The login form that would use this password is not wired. See FR-001.

## Functional requirements

### FR-001 — Two products in one tree

Requirement: the reachable product is the walk-in desk. Registration, company admin, staff admin, and the operations dashboard are in the same tree and are not connected to the PIN session.

Who: any visitor for the desk; a supplier for registration.

Trigger: navigation to `/login` versus `/register`, `/users`, or `/company`.

Inputs: shared secret, or a registration form, or a `Zend_Auth` identity.

Expected behaviour:

- PIN success sets `EVENT.EID = 45` and lands on `/walkins`.
- `/users` and `/company` call `Zend_Auth`. The PIN login never writes that identity, so those actions redirect to `/login/`. `/login/` then sees `EID > 0` and sends the browser back to `/`, which sends it to `/walkins`.
- `LoginNow` authenticates email and password against `CompaniesLogin` and is not called by `indexAction`.

Outputs: session `EVENT`, or a JSON registration result, or a redirect loop back to the desk.

Validation: none beyond the secret comparison and the registration duplicate checks.

Error conditions: wrong secret shows "Bad entered credentials". A missing `Zend_Auth` identity never reaches the company screens.

Dependencies: `Company_Members`, `Companies_Profile`, `CompaniesLogin`, `Company_Events`.

Provenance: **Implemented**.

Implementation: Implemented.

Confidence: High.

### FR-002 — Company picker and passkey

Requirement: the desk lists companies linked to the session event and opens the permit form only when `CEID` and `passkey` match.

Who: desk operator.

Trigger: `/walkins` after FR-001, then the PIN CODE modal.

Inputs: search text; `CEID`; `passkey`.

Expected behaviour: cards for every company whose `Company_Events.EID` equals the session event. The button attribute `ref` is the passkey. The server re-checks both values in `getRow`.

Outputs: company list HTML, or the new-permit form, or a redirect to `/walkins` or `/`.

Validation: positive `id`; matching passkey. The browser check can be skipped by requesting the URL directly; the server check still applies.

Error conditions: no row redirects to `/walkins`. Search SQL errors are printed.

Dependencies: `Companies_Profile`, `Company_Events`, `Company_Members`.

Provenance: **Implemented**.

Implementation: Implemented.

Confidence: High.

### FR-003 — Create a load-in or load-out permit

Requirement: a posted form creates one `Accreditation_Permits` row for the chosen company and the session event, status `2`, permit number `PM` plus a 6-digit id.

Who: desk operator.

Trigger: POST `/permits/addpermit/cid/{CID}`.

Inputs: `data` (load type, service type, reference, request date, time in, time out, job description, vehicle and trailer fields, total staff), `driver`, `Accupant` rows, `cid`.

Expected behaviour: as W3. Load type `1` is Load-In and `2` is Load-Out. Staff type `1` is Permanent Staff and `2` is Casual Staff. Times are 30-minute steps from 00:00 through 23:30. The walk-in date lists drop dates before today.

Outputs: database row. The action then `exit`s with no JSON body. The form's success notice does not depend on a parsed response.

Validation: jQuery Validate on the wizard. The server does not reject an empty job description, a missing vehicle, or a delivery longer than two hours. The FAQ states a two-hour delivery limit. Provenance of that limit: **Documented** only.

Error conditions: insert failure returns false from the model after printing the exception. The identity read after a successful insert is expected to fail. See W3.

Dependencies: `Accreditation_Permits`, `Permits_view`, `Company_Members`, `Accreditation_List` (prefix inside `getRef`, then discarded).

Provenance: **Implemented**.

Implementation: Implemented, with the email step unverified at runtime.

Confidence: High for the insert. Medium for the email.

### FR-004 — Permit profile, save, duplicate, cancel

Requirement: a permit can be viewed, saved, duplicated, or cancelled when the caller has an event session.

Who: anyone who already has `EVENT.EID`. The actions do not check company ownership.

Trigger:

- GET `/permits/profile/key/{PID}` loads `getPermit_admin` (by PID only).
- POST `/permits/savepermit/pid/{PID}` updates occupants and sets `ApplicantModifiedBy` from `$this->identity->MID`.
- `/permits/duplicatepermit` copies a row, clears approval fields, sets status `2`, and reads `$this->identity`.
- `/permits/cancel` sets status `5`, `CancelledBy` from `$this->identity->MID`, and `CancelMessage`. The comment says an email is sent. No email call follows.

Expected behaviour: profile renders when the row exists. Save, duplicate, and cancel touch `$this->identity`, which this controller never sets.

Outputs: profile HTML, or a printed update, or an exit.

Validation: profile redirects to `/` when `getPermit_admin` returns nothing. Save and cancel do not check a lock flag before writing. The profile view computes a lock when the request date at 13:00 on the previous day is already past. The dashboard view uses 10:30 the previous day for rows whose status is below 7. The email says 1pm. Three cutoffs exist. Which one should govern is **Unknown / Requires Confirmation**.

Error conditions: a null identity on save, duplicate, and cancel.

Dependencies: `Accreditation_Permits`, `Permits_view`.

Provenance: **Implemented**. The dashboard that hosts the cancel button is unreachable (FR-001). Profile is reachable by URL if the session exists.

Implementation: Implemented. Duplicate, save, and cancel are not completable without an identity.

Confidence: High.

### FR-005 — Status labels

Requirement: the dashboard and profile views label permit status.

Who: a reader of those views.

Trigger: rendering a permit row.

Inputs: `Status` integer.

Expected behaviour, as written in `index/index.phtml` and `permits/profile.phtml`:

| Status | Label |
|---|---|
| less than 4 | Pending |
| 4 | Approved |
| 5 | Cancelled |
| 6 | Declined |
| 7 | Checked-In |
| 8 | Delayed |
| 9 | Checked-Out |

This repository writes `2` on create and duplicate, and `5` on cancel. No action here writes 4, 6, 7, 8, or 9.

Outputs: badge text.

Provenance: **Implemented** (labels and the two writes). Who sets the other statuses is **Unknown / Requires Confirmation**. **Inferred** that another application sharing `teamwizz_SARU` does.

Implementation: Implemented for labels and for status 2 and 5.

Confidence: High.

### FR-006 — Registration and email confirmation

Requirement: a new company and its first administrator can be created, linked to event `44`, and emailed a confirmation link on `supplier.sarugby.online`.

Who: anonymous visitor.

Trigger: POST `/register/newcompany`. Confirmation: GET `/register/validateemail/id/{key}` on this host, which the email does not use.

Inputs: person fields and company fields from `register/index.phtml`, including password and confirm password (client-side `equalTo` only).

Expected behaviour: W4.

Outputs: JSON `OK` 1, 2, or 0. Confirmation page text, or a redirect to `/login/` when `id` is missing.

Validation: duplicate registration number; duplicate ID number or email. Country string `SOUTH AFRICA` selects ID type 1.

Error conditions: any exception returns JSON `OK=0` and asks the visitor to contact SARU.

Dependencies: `Company_Members`, `Companies_Profile`, `Company_Events`, `BBEE_Level`, `Accre_Company_Types`, `countries`, SMTP.

Provenance: **Implemented**. The 48-hour review is **Documented** only.

Implementation: Implemented.

Confidence: High.

### FR-007 — FAQ

Requirement: `/permits/questions` renders a fixed list from `PermitsController::Questions()`.

Who: a visitor who already has `EVENT.EID` (the controller init redirects otherwise).

Trigger: GET `/permits/questions`.

Inputs: none.

Expected behaviour: the answers describe venue entry, one permit per vehicle per day, a health and safety file, delivery versus work permits, load-in versus load-out, printing, a redacted contact "Danie xxxxxx", a map on the "suppler portal", minimum fields, saving drafts, downloading after approval, a single administrator, HSBC World Rugby Sevens accreditation, casual staff via a STAFF menu, identification, vehicle weight, gate, couriers, subcontractors, and the mailboxes `HealthandSafety7s@sarugby.co.za` and `security@sarugby.co.za`.

Outputs: HTML from `permits/questions.phtml`.

Validation: none.

Several answers describe screens this PIN session cannot reach (STAFF menu, download after approval, supplier portal map). Provenance: **Documented** in the FAQ, **not implemented** as a reachable action in this flow.

Implementation: Implemented as static copy.

Confidence: High.

### FR-008 — Permit PDF

Requirement: `IndexController::getpermitpdfAction` fetches HTML from `https://www.footprintapp.net/pdf/getpermit/id/{id}`, renders it with dompdf as A4 landscape, writes `{AC_PATH}/loadin/pdf/permit_{id}.pdf`, and streams it.

Who: would be a caller of that action.

Trigger: the action. `IndexController::init` redirects before the action runs, so this path does not execute on a normal request.

Inputs: query `id`.

Expected behaviour: as above. `AC_PATH` is not defined in `public/index.php`. `UPDATE_PATH` is defined as `public/archives/`.

Outputs: a PDF stream, or a printed exception.

Provenance: **Implemented** in source, **not reachable** through this controller's init.

Implementation: Present, not dispatched.

Confidence: High.

### FR-009 — Switch event without the shared secret

Requirement: `/login/external` copies query parameter `eid` into `EVENT.EID` and redirects to `/walkins`.

Who: anyone who can request the URL.

Trigger: GET `/login/external?eid={id}`.

Inputs: `eid`. No comparison with the shared secret.

Expected behaviour: the walk-in desk opens for that event id.

Outputs: redirect.

Validation: none.

Provenance: **Implemented**.

Implementation: Implemented.

Confidence: High.

## Integrations

| System | Purpose | Direction | Trigger | Auth | Failure |
|---|---|---|---|---|---|
| MySQL `teamwizz_SARU` on `teamwizz.com` | All reads and writes | Outbound | Every action that constructs a model | Username and password in `application.ini`. Values omitted here | Models print the exception and often return false |
| SMTP `mail.sarugby.co.za:25` | Registration, reset, and permit mail | Outbound | Those actions | Username `accreditation@sarugby.co.za`. Password omitted | Caught and printed in `PermitsController::sendMail`. Registration and login swallow the exception |
| `http://supplier.sarugby.online/register/validateemail/id/{key}` | Email confirmation link | Outbound link in mail | Registration | None in the link | This host's own `validateemail` is not the link target |
| `https://www.footprintapp.net/pdf/getpermit/id/{id}` | PDF HTML | Outbound GET | `getpermitpdfAction` | None in this code | Exception printed. Action is behind the index redirect |
| `https://footprintapp.net/email/send` | Alternate mail sender | Outbound POST | `postEmailToFp` | None in this code | The only call is commented out |

## Constraints

- Timezone is `Africa/Johannesburg` in `public/index.php`.
- Session cookie lifetime in the ini file is `864000` seconds (10 days).
- Default locale in the ini file is `af_ZA`. No language switch is active in the navigation partial.
- PHP `mcrypt` is required for the random ids used at registration and reset.
- `display_errors` is forced on in `public/index.php` even though the production ini sets it off.
- A maintenance message in `public/index.php` is commented out. The site does not stop itself.

## Error behaviour

- Wrong shared secret: inline message, stay on the login page.
- Missing event session on walk-in or permits: redirect to `/login/`.
- Registration duplicate or exception: JSON `OK` 2 or 0.
- Forgot-password for an unknown email: the page stays, `SENT` is not set. No "not found" message was found.
- Reset with an unknown key: view flag `SENT` 2.
- Model failures: `print_r` of the exception in several models, including `AccreditationPermits::updateData`, which also prints the payload it is about to save.

## Planned behaviour

No roadmap, issue list, or TODO file is in the repository. Commented code (maintenance page, footprint email post, dashboard counts inside `IndexController::indexAction`) is leftover, not a stated plan.

Provenance: **Unknown**. Nothing in Git describes future work.
