# PRD — magus-core

Evidence date: 2026-10-09. Source: `git@bitbucket.org:overdrive/magus-core.git`, `master` at `f806a7e` (2019-08-19).

There is no prior PRD in the repository or in Silicon Ark. Requirements below were recovered from controllers, views, models, `application.ini`, and the `admin_magus.sql` dump (phpMyAdmin, generated 2019-03-15, database name `admin_magus`). Provenance is marked on each item.

The README states only: "The core source code for the Magus PHP Application". The layout title states "Magus Events - Accreditation Portal".

## Product purpose

A signed-in operator manages companies, users, venues, and accreditation events, and records vehicle load permits against an event. A supplier can register a company without being signed in. Email copy tells the supplier that SA Rugby will review the registration (up to 48 hours) and that the supplier must confirm their email before the account can be used.

Provenance: **Documented** in email HTML inside `RegisterController::newcompanyAction` and in the layout title. The 48-hour review is copy only; the code path that confirms the email sets `AllowLogin = 1` immediately and does not record an administrator approval. See FR-012.

## Users and roles

Lookup rows in `UserRoles` (dump):

| RID | Role | Active flag in dump |
|---|---|---|
| 1 | Administrator | 0 |
| 2 | Director | 0 |
| 3 | Management | 0 |
| 5 | Operations | 0 |
| 6 | Driver | 0 |
| 7 | Technician | 0 |
| 8 | General Staff | 0 |

RID 4 is absent. `Active` is 0 for every seeded role. The application does not read `UserRoles.Active` before showing a role in the user form. Provenance: **Implemented** seed data; the Active flag is unused by the controllers that were read.

`users.Rol` is a varchar. Registration writes the string `Administrator`, not RID `1`. The user list joins `UserRoles.RID = users.Rol`. A string role will not match RID 1. Provenance: **Implemented** (`RegisterController`). Whether production data stores RIDs or names is **Unknown / Requires Confirmation**.

| Actor | What they do | Evidence | Provenance |
|---|---|---|---|
| Anonymous visitor | Opens `/login`, `/login/forgot`, `/login/reset`, `/register` | Controllers disable layout and do not require `Zend_Auth` | Implemented |
| Registered supplier user | Submits company plus personal details, then opens the email link | `RegisterController::newcompanyAction`, `validateemailAction` | Implemented, but depends on classes that are not in the repo |
| Signed-in user | Any identity in `Zend_Auth` can open the sidebar screens. No role check was found on those actions | `init()` of Companies, Users, Venues, Events, Accreditation, Roles, Types, Permits, Index | Implemented |
| Applicant on a permit | Stored as `users.MID` / `users.CID` on `Accreditation_Permits` | `PermitsController::addpermitAction` | Implemented |
| Mail recipient | Receives HTML from `accreditation@sarugby.co.za` | SMTP settings inside four controllers | Implemented |

`companyType` also has an `admin` column. The only seeded type with `admin = 1` is "Administrators". Controllers that were read do not branch on that flag.

The sidebar (`nevigation.phtml`) is the same for every signed-in identity. No permission matrix is implemented.

## Main workflows

### W1 — Sign in

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

1. If `Zend_Auth` already has an identity, redirect to `/`.
2. Unless the password equals a hardcoded literal in `LoginController::indexAction`, the password is MD5-hashed. The lines that would replace that literal with a stored hash are commented out, so a match sends the literal itself to the database adapter.
3. `Zend_Auth_Adapter_DbTable` checks `users.Email` and `users.password`.
4. On success, the whole user row is stored as the identity and the browser goes to `/`. On failure the view shows "Bad entered credentials".
5. `AllowLogin` and `Email_Confirmed` are not read.

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

Provenance: **Implemented**. The literal password value is omitted from this PRD on purpose.

### W2 — Register a company

Trigger: GET `/register`, then POST `/register/newcompany`.

1. The form is given B-BBEE levels, company types, and countries.
2. Company types are loaded with `Model_AccreCompanyTypes`, which is not in the repository. `Model_CompanyTypes` (table `companyType`) is what the signed-in company screens use.
3. If `RegistrationNumber` already exists, or the ID number or email already exists, the JSON response is `OK: 2` and the message tells the person to contact SARU.
4. Otherwise the user is inserted with MD5 password, `Rol = "Administrator"`, `DateCreated` today, `IDType` 1 when country is the string `SOUTH AFRICA` and 2 otherwise, and a random `Ref`.
5. The company is inserted through `Model_CompanyProfile` (class not in the repo) with `RefNumber`, `CreatedBy`, `CreatedDate`, and `AdminID` set to the new user.
6. `Model_CompanyEvents` (class not in the repo) inserts `CID`, `EID = 44`, `Status = 2`. Which accreditation `44` is was not established from the dump text fields that could be read without binary blobs.
7. A validation key is stored and an email is sent. The link host is hardcoded: `http://supplier.sarugby.online/register/validateemail/id/{key}`.
8. The email says an administrator will review the company and that activation can take up to 48 hours, and that extra documents are required before the company can connect to an event.

Provenance: **Implemented** in `RegisterController`, with missing classes called out above. The 48-hour wait is **Documented** in that email and is not enforced in code.

### W3 — Confirm email

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

If `users.Validate_Key` matches, the key is cleared and `Email_Confirmed`, `Email_Confirmed_Date`, and `AllowLogin = 1` are set. The page says the account is active and links to `/login`. A missing key says the account may already be activated. No key redirects to `/login`.

Provenance: **Implemented**. This contradicts the 48-hour review sentence in W2.

### W4 — Reset password

Trigger: POST `/login/forgot` with `Email`, then GET/POST `/login/reset/user/{ResetKey}`.

1. A user with that email gets a new `ResetKey` (MD5 of 4 random bytes from `mcrypt`).
2. The email contains `http://{current host}/login/reset/user/{key}` and the SA Rugby accreditation address.
3. POST of a new password stores MD5, clears `ResetKey`, and sets `AllowLogin = 1` and `Email_Confirmed = 1`.
4. A second email repeats the new password in clear text.

If the reset key is missing, the action redirects to `/login`. If the key is unknown, the view flag `SENT` is 2.

`forgotAction` only sets the success flag when a user was found and the update saved. An unknown email produces no message in the code that was read.

Provenance: **Implemented**.

### W5 — Staff lists

Trigger: signed-in user opens a sidebar URL. DataTables then calls a `list` (or `listtypes`) action.

| Screen | URL | List action | Row identity |
|---|---|---|---|
| Dashboard | `/` | `IndexController::indexAction` is empty | — |
| Companies | `/companies` | `/companies/list` | `Companies.CID` |
| Users | `/users` | `/users/list` | `users.MID` |
| Venues | `/venues` | `/venues/list` | `Venues.VID` |
| Events | `/events` | `/events/list` | `Accreditation_List.AC` via `Model_Events` |
| Accreditations label | `/events` (same href) | same | same |
| Load permits label | `/events` (same href) | same | same |
| Company types | `/types/` | `/types/listtypes` | `companyType.type_id` |
| User roles | `/roles/` | `/roles/list` | `UserRoles.RID` |

Accreditation has its own controller at `/accreditation` (list uses the same `Model_Events` / `Accreditation_List`), but the live sidebar does not link to it. `main_menu.phtml` is not the menu the layout includes.

Provenance: **Implemented**.

### W6 — Submit a load permit

Trigger: signed-in POST to `PermitsController::addpermitAction`.

1. Occupants and a driver are collected. Type `1` occupants are treated as people already on the system, but the lookup that would fill their name from `users` is commented out, so only posted fields are kept.
2. Occupants are stored as JSON in `Accreditation_Permits.Accupants`.
3. `Status` is set to `2`.
4. `PermitNr` is first requested from `getRef`, which needs missing class `Model_AccreditationList`, then overwritten with `PM` plus zero-padding plus the new `PID`.
5. `PermitRefSerial` is a random zero-padded digit string.
6. `EID` is `$this->_EID`. That property is declared and not assigned in `PermitsController`, so the value written is whatever PHP supplies for an unset property (typically null). `EventsController` is a separate copy of permit save/update.
7. If the permit row's `Surname` equals `2`, an email is sent describing the request as received and pending administrator review. `postEmailToFp` exists and is commented out at this call site.

Provenance: **Implemented**, including the gaps above.

### W7 — Lock a permit the day before

On the permit profile actions, `LOCKED` is 1 when `RequestDate` at 13:00 minus one day is already in the past. The view receives the flag. What the view does with it was not fully traced. Provenance: **Implemented** in `PermitsController::profileAction` and `profiletestAction`.

## Functional requirements

### FR-001 — Portal identity

The signed-in shell title is "Magus Events - Accreditation Portal".

Evidence: `application/layouts/default.phtml`.

Implementation: Implemented.

Confidence: High.

### FR-002 — Authentication gate

Companies, users, venues, events, accreditation, roles, types, permits, index, and load-in require an auth identity. Login, register, authon, walk-ins, monster, geo, and the error controller do not.

Load-in redirects a missing identity to `/companies/login/`. `CompaniesController` has no `loginAction`.

Evidence: each controller `init()`.

Implementation: Implemented.

Confidence: High.

### FR-003 — Credential check

Login matches email and MD5 password on `users`, except for the hardcoded literal described in W1.

Evidence: `LoginController`.

Implementation: Implemented.

Confidence: High.

### FR-004 — Session length

Session cookie lifetime is `864000` seconds (10 days) for every INI environment.

Evidence: `application/configs/application.ini`.

Implementation: Implemented.

Confidence: High.

### FR-005 — Company registration uniqueness

Registration refuses a company registration number that already exists, and refuses a user whose ID number or email already exists. Both failures return the same SARU contact message.

Evidence: `RegisterController::check_Company`, `check_IDNumber`, `newcompanyAction`.

Implementation: Implemented. Company lookup uses missing class `Model_CompanyProfile`.

Confidence: Medium. The rule is written; this tree cannot execute the company half.

### FR-006 — South African identity type

If the posted company country equals `SOUTH AFRICA`, `users.IDType` is 1, otherwise 2. The comparison is the literal string, not `countries.id`.

Evidence: `RegisterController::newcompanyAction`. `CompaniesController::addAction` repeats this rule.

Implementation: Implemented.

Confidence: High.

### FR-007 — Email confirmation

A valid `Validate_Key` confirms the email and sets `AllowLogin` to 1.

Evidence: `RegisterController::validateemailAction`.

Implementation: Implemented.

Confidence: High.

### FR-008 — Password reset

A reset link is single-use (`ResetKey` cleared on success). The new password is emailed back in clear text. Success also forces `AllowLogin` and `Email_Confirmed` to 1.

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

Implementation: Implemented.

Confidence: High.

### FR-009 — Company catalogue

Signed-in users can list, add, and edit companies. List search covers legal name, trading name, registration number, registration date, VAT number, company type name, and country name. Add repeats the registration user/company creation path and leaves the event insert commented out.

Evidence: `CompaniesController`, `Model_Companies`.

Implementation: Implemented.

Confidence: High.

### FR-010 — Company types

Seeded types: Suppliers, Consultants, Service Providers, Distributors & Manufacturers, Agencies, Institutions, Administrators (`admin = 1`). Signed-in users can add, edit, delete, and list them at `/types`.

Evidence: `companyType` dump; `TypesController`.

Implementation: Implemented.

Confidence: High.

### FR-011 — Users

Signed-in users can list, add, edit, delete, and upload a profile image. Add hashes the password with MD5, sets `IDType` to 1, and rejects a duplicate ID number or email. The list can filter by role id. Delete is `deleteusersAction`.

Evidence: `UsersController`, `Model_UserProfile`.

Implementation: Implemented.

Confidence: High.

### FR-012 — User roles

Seeded roles are listed under Users and roles. Signed-in users can add, edit, and delete roles. The form stores the role name only.

Evidence: `UserRoles` dump; `RolesController`.

Implementation: Implemented.

Confidence: High.

### FR-013 — Venues

A venue has name, country, province, city, suburb, street, capacity, owner, owner contact, coordinates, field count, type, status, and description. Status labels in the list: `1` Active, `2` Inactive, anything else Abandoned. Add and edit resolve province, city, and suburb ids from posted address names.

Evidence: `Venues` table; `VenuesController`.

Implementation: Implemented.

Confidence: High.

### FR-014 — Accreditation events

`Model_Events` reads and writes `Accreditation_List`, not the `Events` table. The list shows accreditation name, code, date from, date to, and a status:

- Default label "Closed".
- If `strtotime(DateTo)` is greater than `strtotime` of today's `d-m-Y` string and `Active` is 1, label "Active" and edit is offered.
- Same date test and `Active` not 1: "Inactive", edit removed.
- "Closed" removes edit and delete.

The `d-m-Y` comparison is what the code does. Whether it matches calendar intention for the stored datetime is **Unknown / Requires Confirmation**.

Evidence: `AccreditationController::datatable`, `Model_Events` (`$_name = Accreditation_List`). `EventsController::listAction` uses the same model.

Implementation: Implemented.

Confidence: High for the mapping; medium for the date comparison's product meaning.

### FR-015 — Duplicate Events table

`admin_magus.sql` also creates `Events` with the same columns as `Accreditation_List`. No model sets `$_name` to `Events`.

Evidence: dump; `application/models/Events.php`.

Implementation: The table is in the dump. Application use of it was not found.

Confidence: High.

### FR-016 — Load permits

A permit stores status, numbers, event id, company id, load type, service type, gate, zones (text/JSON), request date, time in and out, job description, vehicle and trailer fields, driver, occupants JSON, and audit columns for confirm, approve, decline, cancel, and applicant edit.

New permits from `PermitsController::addpermitAction` are inserted at status `2`.

Evidence: `Accreditation_Permits`; `PermitsController`.

Implementation: Implemented, with the missing-class and unset `_EID` gaps in W6.

Confidence: Medium.

### FR-017 — Permit statuses

The dump's `Permit_Status` rows are the names stored for operators. `EventsController` labels some of the same integers differently. Both are recorded in business rules. This PRD does not pick a winner.

Evidence: `Permit_Status` insert; `EventsController` around the stats array and `permits_datatable`.

Implementation: Conflicting. Requires confirmation.

Confidence: High that the conflict exists.

### FR-018 — Permit types and vehicle classes

Permit types: Work Permit (1), Delivery Permit (2). Loading service types in the dump use the same two names (with trailing spaces).

Vehicle types are classes 1–4 from "No vechile - walk-in only" through heavy trucks, with an occupant count on each row (`vehicletypes.Accupants`).

Evidence: dump tables `PermitType`, `Accreditation_Load_Services_Types`, `vehicletypes`.

Implementation: Implemented as seed data. The commented stats block treats service type 1 as work and 2 as delivery.

Confidence: High.

### FR-019 — Loading zones and gates

Zones belong to an accreditation (`AID`) and store number, name, abbreviation, colour, dimensions, and parking bay limits (normal, disabled, bus) plus max weight. Gates belong to an accreditation (`EventGates.AC`).

The commented weight check treats a zone as over limit when summed vehicle and trailer weight exceeds `(ParkingBaysLimit + ParkingDisabledLimit + ParkingBusLimit) * MaxWeight`. That block is inside a comment, so it does not run.

Evidence: dump; `EventsController::getpermitsAction`.

Implementation: Data model implemented. The limit check is not executed.

Confidence: High.

### FR-020 — B-BBEE levels

Levels 1 through 7 are seeded. Registration and company add load them for the form. `Companies.BBEECompliance` is a varchar.

Evidence: `BBEE_Level`; `RegisterController::indexAction`; `CompaniesController::addAction`.

Implementation: Implemented.

Confidence: High.

### FR-021 — Geography

Countries, provinces, cities, and suburbs are tables. `countries` includes `rugby_nation` and `fp_client` flags that this application's controllers do not read. Venue and company forms also call Google Geocoding through `GeoController` (no login required).

Evidence: models; `GeoController`; dump.

Implementation: Implemented.

Confidence: High.

### FR-022 — Dashboard and stub screens

`IndexController::indexAction` assigns nothing. `WalkinsController`, `MonstorController`, and `AuthonController` have empty or exit-only actions. `AuthonController` has a register view file and an empty `newcompanyAction`. `LoadinController::indexAction` is empty. `main_menu.phtml` links Employees and Events to `#`.

Evidence: those controllers and views.

Implementation: Shells only.

Confidence: High.

### FR-023 — Photo files

Profile and company images are written under `public/photos/` (`UPDATE_PATH`). User image path is `{MID}_image.jpg`. Company image path is `companies/{cid}_image.jpg`.

Evidence: `public/index.php`; `UsersController::imageuploadAction`; `LoginController::companypicAction` and `uploadremotecompanyAction`.

Implementation: Implemented. `uploadremotecompanyAction` and `companypicAction` use missing class `Model_CompanyProfile` for the company-row update. `companypicAction` still streams a file if it exists.

Confidence: Medium.

## Integrations

| System | Direction | Trigger | Data | Failure behaviour |
|---|---|---|---|---|
| MySQL `magus` (INI) / `admin_magus` (dump) | App reads and writes | Every model call | Companies, users, events, permits, lookups | Exceptions are often `print_r`'d. No user-facing retry. |
| SMTP `mail.sarugby.co.za:25` | Outbound | Register, forgot password, reset, permit submit | HTML body, recipient, subject "SARugby Notification" | Exceptions are swallowed. The password is hardcoded and is not recorded here. |
| `supplier.sarugby.online` | Link only | Registration email | Validation URL | Not called as an API. |
| `www.sarugby.co.za` | Link only | Login and register footers | Terms URL | Not called as an API. |
| Google Geocoding | Outbound HTTP | `/geo/*` address actions | Address string, API key in source | Empty keyword sends nothing. No auth on the controller. |
| `https://footprintapp.net/email/send` | Outbound POST | `PermitsController::postEmailToFp` | Email, full name, HTML content | Method exists. The call in `addpermitAction` is commented out. |
| FS Stadium Management | Named only | Column comment `PostExternal`: 0 no post, 1 FS Stadium Management | No client code found | Unknown |

Barcode options are comments on `Accreditation_List.BarcodeOption`: 0 Reference, 1 Payload, 2 Serial Range, 3 GateKeeper. No barcode renderer was found in the controllers that were read.

## Important constraints

- Passwords are MD5, not a slow hash. Reset email includes the new password.
- Production INI turns `display_errors` and `displayExceptions` on (`testing` turns display_errors off; `development` turns `displayExceptions` off).
- Queries concatenate request values into SQL (`IDNumber`, `Email`, `ResetKey`, DataTables search).
- `GeoController` and the login literal are unauthenticated or bypass paths. Details are in technical documentation. Secret values are not copied here.
- `admin_magus.sql` is a full data dump, including user rows. It is personal data in git.
- Registration cannot complete in this tree until `Model_CompanyProfile`, `Model_AccreCompanyTypes`, and `Model_CompanyEvents` exist.
- Login does not honour `AllowLogin`.

## Behaviour that is documented but not enforced

| Copy | Code |
|---|---|
| Administrator review, up to 48 hours, before the company profile is activated | Email confirmation sets `AllowLogin = 1` immediately and does not set `Companies.Active` |
| Extra documents are required before connecting to an event | No document checklist was found in `RegisterController` |
| Permit request "will be reviewed by an administrator" | Insert sets status 2 and emails; no separate approval transaction was found in `addpermitAction` |

Provenance of the copy: **Documented** in the email strings. Provenance of the code: **Implemented**.
