# PRD — permits.mypass.zone

Evidence date: 2026-10-07. Source: `git@bitbucket.org:overdrive/permits.mypass.zone.git`, `master` at `16d1793` (2022-07-01).

There is no prior PRD in the repository or in Silicon Ark. 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 PIN, 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 SA Rugby event.

The login page asks for a PIN. A matching PIN 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. 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 PIN, 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 receives the confirmation email | `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).
- If the value equals the hardcoded PIN, 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. The dashboard code in `indexAction` does not run.

`WalkinsController::indexAction` loads companies for that event through `Company_Events` joined to `Companies_Profile`, the admin `Company_Members` row, and `AccreditationMemberProfile`. 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`), 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 form posts to `/permits/addpermit/cid/{CID}`.

`PermitsController::addpermitAction` runs only for POST:

1. Occupants with a non-empty name are kept. Type `1` 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 the 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`.

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

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 PIN path and the supplier-admin path are both in this repository, and only the walk-in path is connected to the login form.

| Path | Session it needs | What login actually does |
|---|---|---|
| Walk-in desk | `EVENT.EID` | Sets `EID` to `45` |
| Dashboard at `/` | `EVENT.EID`, and it also reads `Zend_Auth` identity in commented blocks | `init` redirects to `/walkins` before `indexAction` |
| Users, company profile, permit save / cancel / duplicate | `Zend_Auth` identity with `MID` and `CID` | PIN login never writes that identity |

`LoginNow` authenticates email and password against view or table `CompaniesLogin` and writes the identity. Nothing in the login form calls it. A view `login/auth.phtml` shows an email and password form and a forgot-password link. There is no `authAction`.

Provenance: **Implemented**. Which path is the product to keep is **Unknown / Requires Confirmation**.

### FR-002 — Shared PIN

The login POST accepts one hardcoded PIN and stores event `45`. Any other value shows "Bad entered credentials". An existing `EID` greater than 0 skips the form and goes to `/`.

Provenance: **Implemented**.

### FR-003 — Unauthenticated event switch

`GET /login/external` with parameter `eid` writes that value into `EVENT.EID` and redirects to `/walkins`. No PIN is checked.

Provenance: **Implemented**. Whether this route is still used by another OverDrive system is **Unknown / Requires Confirmation**.

### FR-004 — Company list

The desk lists companies that have a `Company_Events` row for the session event, ordered by `LegalName`, grouped by company id. Search matches legal name, trading name, company type, reference number, first name, or surname.

Every card renders "New Permit", including when the passkey is empty. A commented condition that would have required `Ref` is disabled.

Provenance: **Implemented**.

### FR-005 — Passkey

The modal compares the typed PIN with the passkey embedded in the page. The server accepts `/walkins/new` only when `CEID` and `passkey` match `Company_Events`.

Provenance: **Implemented**. The passkey is visible in the HTML of the company list. That is a constraint, not a separate product rule.

### FR-006 — Permit form fields

The form posts `data[...]`, `driver[...]`, and `Accupant[n][...]`.

| Field | Stored as | Notes |
|---|---|---|
| Load type | `data[LoadType]` | `1` Load-In, `2` Load-Out. Dates shown depend on this choice |
| Service type | `data[ServiceType]` | Options from `Accreditation_Load_Services_Types` |
| Your reference | `data[YourRefNumber]` | Free text |
| Date | `data[RequestDate]` | Options are active dates on or after today, value `m/d/Y` |
| Time in / time out | `data[TimeIn]`, `data[TimeOut]` | 30-minute steps from 00:00 through 23:30, default selected `19:00`. Labels are "Time In" and "Time Out". The select placeholder text still says "Select a Vehicle Type..." |
| Job description | `data[Job_description]` | Textarea marked `required` in the HTML class |
| Vehicle type, model, licence, weight, height | `data[VehicleType]`, `VehicleModel`, `VehicleLicense`, `VehicleWeight`, `VehicleHeight` | Types from `vehicletypes` |
| Trailer | `HasTrailer`, `TrailerLicense`, `TrailerWeight`, `TrailerHeight` | Checkbox value `1` |
| Driver | `driver[Name]`, `Surname`, `ID`, `Mobile` | Omitted from `Accupants` when the name is empty |
| Occupants | `Accupant[n][Type]`, `Name`, `Surname`, `ID` | Rows with an empty name are dropped |
| Total staff | `data[TotalStaff]` | Defaults to `0` in the form |

No server action checks that time-out is after time-in, that a delivery is at most two hours, or that driver ID and vehicle licence are present. The FAQ describes those as requirements. See business rules.

Provenance: **Implemented** (form and `addpermitAction`). FAQ items that the server does not check are **Documented**.

### FR-007 — Permit number and status on create

New permits are stored with `Status = 2` and `PermitNr = PM` plus the insert id padded to 6 characters. Example shape: `PM000123`. The earlier `getRef` value is replaced.

Provenance: **Implemented**.

### FR-008 — Confirmation email

After insert, the action emails the address returned with the permit. The body states:

- The request is for Load-In or Load-Out. The code chooses Load-Out when `$permit["Surname"] == 2`, otherwise Load-In. `Surname` on this row is the applicant surname from the view, not the load type. Provenance: **Implemented**. The load-type sentence is **Inferred** to be wrong whenever the surname is not the number 2.
- Permit number format `{PermitNr}/{year of RequestDate}`.
- Changes are accepted until 1pm. Next-day requests after that time are not accepted.
- Load-out time is restricted and approved first come, first served.
- Administrators may change the requested date and time.
- Work permits require the 14-item health and safety file listed in the email and again in the FAQ.
- Enquiries: `accreditation@sarugby.co.za`. Contact centre `+27219287000`.

Provenance: **Documented** in the template. Delivery depends on the identity bug in W3.

### FR-009 — Status labels on the dashboard

The dashboard view, which the PIN login cannot reach, labels status as:

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

The same view also increments a "declined" counter when status is `4`, and an "approved" counter when status is `4`. Status `0` increments both "pending" (because it is less than 4) and "saved". Provenance: **Implemented** in `index/index.phtml`. The two uses of status `4` disagree. **Requires Confirmation** which label is correct. No code in this repository sets status `4`, `6`, `7`, `8`, or `9`.

`cancelAction` sets status `5`, `CancelledBy` to `$this->identity->MID`, `CancelledDate` to now, and `CancelMessage` from the request. It prints the data array and exits. It depends on the missing identity.

### FR-010 — Profile and edit

`/permits/profile/key/{id}` loads `Permits_view` by `PID` only (`getPermit_admin`). It does not filter by company. A missing row redirects to `/`.

`/permits/profiletest/key/{id}` loads by `PID` and `$this->identity->CID`. Identity is unset, so this filter does not work as written.

Both actions split `Accupants` into a driver and other people and build load-in and load-out date lists from the permit row. `LOCKED` is `1` when 13:00 on the day before `RequestDate` is already past.

`savepermitAction` and `duplicatepermitAction` update or copy a permit and read `$this->identity`. Duplicate clears gate, zones, and the approval, decline, cancel, and confirm fields, sets status `2`, and assigns a new `PermitNr` from `getRef` (it does not apply the later `PM` overwrite). Duplicate sets `CID` and `MID` from the missing identity.

Provenance: **Implemented**, blocked by the missing identity for every action that reads it. `profileAction` itself does not read identity.

### FR-011 — FAQ

`/permits/questions` renders the question list returned by `Questions()`. The answers are the only in-app statement of several venue rules (printed permit, identification, one permit per vehicle per day, delivery versus work, load-in versus load-out, one company administrator, health and safety file). Provenance: **Documented**. The action still requires the event session.

### FR-012 — Registration validation responses

| Condition | JSON |
|---|---|
| Company and person are new, inserts succeed | `OK=1`, thank-you message |
| Registration number, ID number, or email already exists | `OK=2`, contact SARU |
| Exception | `OK=0`, apology, contact SARU |

`IndexController::newcompanyAction` is a second, older registration path. It is unreachable because `init` redirects first. It links email validation to `http://www.sarugby.online/company/index/validateemail/id/{member id}` and does not hash the password. Provenance: **Implemented** but not reachable.

### FR-013 — Logout

`/login/logout` clears `Zend_Auth` and destroys the session, then redirects to `/login`.

Provenance: **Implemented**.

## Integrations

| System | Direction | Trigger | Data | Failure |
|---|---|---|---|---|
| MySQL `teamwizz_SARU` on `localhost` | Read and write | Every page that passes the session gate, plus registration | Companies, events, permits, members, accreditation lists | Exceptions are often printed with `print_r`. No user-facing retry |
| SMTP `mail.sarugby.co.za`, port 25, from `accreditation@sarugby.co.za` | Outbound | Registration, permit create, forgot password, reset, and `emailtestAction` | HTML body | Exceptions are printed or swallowed. The caller is not told the send failed |
| `supplier.sarugby.online` | Link only | Registration email | Validate-key URL | This app does not call that host. If the link is wrong, confirmation never hits `RegisterController::validateemailAction` |
| `https://footprintapp.net/email/send` | Outbound POST | Commented out in `addpermitAction` | Email, full name, HTML | Not active |
| `https://www.footprintapp.net/pdf/getpermit/id/{id}` | Inbound HTML | `IndexController::getpermitpdfAction` | Permit id in the URL, response rendered with dompdf to A4 landscape | The action is behind the redirect to `/walkins`. `AC_PATH` is not defined anywhere in this repository |

Authentication to SMTP is a username and password in the controller source. They are not repeated here.

## Constraints

- Timezone is `Africa/Johannesburg` (`public/index.php`).
- Session cookie lifetime is `864000` seconds (10 days) in `application.ini`.
- Default locale in config is `af_ZA`. The views inspected are English.
- `resources.frontController.params.displayExceptions` is `0`. `public/index.php` and the controllers turn `display_errors` on anyway.
- Passwords that are stored use `md5` with no salt (`RegisterController`, `UsersController`, password reset).
- Queries concatenate request values into SQL (`Walkins` search, member lookup by ID number, reset-key lookup).
- `mcrypt` is required for the random ids used as references and reset keys.
- The production maintenance splash in `public/index.php` is commented out. The site boots the Zend application.

## Error behaviour

- Unknown controller or action: HTTP 404, message "Page not found" (`ErrorController`). Logging runs only if a Log bootstrap resource exists. `Bootstrap` does not register one.
- Other exceptions: HTTP 500, message "Application error". The exception object is shown only when `displayExceptions` is true, which the ini sets to `0`.
- Missing event session: redirect to `/login/`, not an error page.
- Missing permit on profile: redirect to `/`.
- Registration and user-save actions return JSON and `exit`, including on failure.
- `addpermitAction`, `cancelAction`, and `duplicatepermitAction` `exit` with no JSON success body. Duplicate prints the exception on failure.

## Planned behaviour

No roadmap, TODO file, or ticket list is in the repository. Email copy describes administrator approval, a 48-hour registration review, and health-and-safety approval. Those steps are **Documented** and not implemented here.

`bitbucket-pipelines.yml` deploys branch `qa` and branch `prod`. Remote branches are `master`, `prod`, and `ui-changes`. A `qa` environment is named and not present as a branch. Provenance: **Implemented** configuration. Live state is **Unknown / Requires Confirmation**.
