# PRD — sarugby (SA Rugby)

Evidence date: 2026-10-04.

Source: `git@bitbucket.org:overdrive/sarugby.git`, `master` at `6048eba` (2019-04-28).

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from `Readme.md` and from the Zend Framework 1 application. Provenance is labelled. Secrets in `application.ini` and mail code are not reproduced.

## Product purpose

**Documented.** `Readme.md` says this is the code base for `https://sarugby.online/`, with its own frontend and an API backend for other SARU products.

**Implemented.** A browser can open a Zend Framework 1 site whose default layout title is "SA Rugby - Official Home of the Springboks". The same codebase also serves a company accreditation area, a catering menu, and a Blue Card concussion form.

**Unknown / Requires Confirmation:** whether "API backend" means the JSON files written under `public/archives`, the `default` ajax and cron actions, or a separate service that is not in this tree.

## Users and roles

| Actor | Evidence | Status |
|---|---|---|
| Public visitor | `default` home and tournament actions. No login check on those actions | **Implemented** |
| Company applicant | `companies/register`. New member `Rol` is set to `Administrator` | **Implemented** |
| Company member | `companies` login against DB table or view `CompaniesLogin`. Later screens require `Zend_Auth` | **Implemented** |
| Company member types | `UsersController` offers Administrator, Director, Management, Operations, Driver, Technician, General Staff | **Implemented** as a list stored on the member. No further permission split was found in these controllers |
| Blue Card reporter | Form offers Referee, Coach, Team Manager, Physiotherapist, Parent, Guardian, Player | **Implemented** as form options. The action does not save a submission |
| Default-module account | `default/login` uses table `co_credetials` and class `Model_Credentials` | **Implemented** controller. The model class is **not in git**. Mail copy says Footprint / SARU E-Filing |
| Cron or operator hitting `/default/cron/...` | No auth check in `CronController::init` | **Implemented** as an open action |
| Deploy operator | README suggests an HHVM container. No deploy scripts | **Documented** suggestion only |

## Systems the product talks to

1. The visitor's browser, through Apache rewrite into `public/index.php`.
2. MySQL database `teamwizz_SARU` on the host recorded in `application/configs/application.ini` (adapter `pdo_mysql`). Credentials are in that file and are not repeated here.
3. The filesystem path `UPDATE_PATH`, defined as `public/archives/`. That directory is gitignored.
4. SMTP `mail.sarugby.co.za` port 25 as `accreditation@sarugby.co.za` (companies registration, password reset, permits).
5. `Zend_Mail` with from-address `support@footprint.net` and the name "SARU E-Filing" (default login reset). Transport is the Zend default, not the sarugby SMTP block.
6. `https://fsrugby.appwizard.co.za/scripts/AppWiz.dll` by POST from `CronController::senddata_to_Api` when accreditation code `AC` is `14`. A commented alternative is `https://footprintapp.net/test/recievedata`.
7. Google Chart Image API, used as an image URL for a menu QR code.
8. External copy links in the Blue Card thank-you text: `www.sportsconcussion.co.za`, `www.BokSmart.com/Concussion`, and phone numbers printed in the view. The application does not call those sites.
9. Theme assets still reference `demo.themeum.com` (Sportsline WordPress demo). Those are links and script URLs in the layout, not a live integration owned by this code.

## Major workflows

### WF-1 — Open the public home page

`IndexController::indexAction` (module `default`):

- Year comes from query parameter `year`, or the current year if empty.
- Reads `{year}.json` with `file_get_contents` on a bare filename (not `UPDATE_PATH`).
- Loads tournaments with `Hide != 1`, `LastUpdate` at or after the file's `Updated` stamp (default `1977-01-01 00:00:00`), and `StartDate` on or after `{year}-01-01`.
- Writes base64 images to `public/archives/images/tournaments/` and JSON to `public/archives/tournaments/`.
- Groups rows for the view: `HightedTourn == 1` into Featured; `Level` 1, 2, and 3 into international, national, and provincial; each split into Current, Completed, and Upcoming by comparing start and end dates to today.
- Upcoming groups are sorted by `StartDate` ascending.
- Also assigns latest results (`fixturesResultsTest2`, limit 8 from view `GameResults_view`, `Hide != 1`) and upcoming fixtures.

Provenance: **Implemented**. The Sportsline markup around that data is theme HTML. Many links in `default.phtml` still point at the Themeum demo.

A commented block at the top of `public/index.php` would replace the whole site with a maintenance message and `Background4_2018_maintenance.jpg`. It is not active.

### WF-2 — Open a tournament

`TournamentsController::profileAction` reads `public/archives/tournaments/{tour}.json`, then loads logs, matches, stats, results, and upcoming fixtures for that id (`tour` query parameter).

Related actions on the same controller: `fixtures`, `result`, `resultold`, `match`, `matchtest`, `team`, `commentary`, `getmatchreport`, `news` (empty), `profiletest`.

`resultoldAction` writes JSON using the constant `UPDATES_PATH`, which is not defined in `public/index.php`. `UPDATE_PATH` is defined. Provenance: **Implemented** call to an undefined constant.

### WF-3 — Refresh JSON caches

`AjaxController::getdataAction` reads `public/archives/lastupdate.txt`, then rewrites tournament, log, and squad JSON for rows whose `Updated` is newer than that stamp, then writes a new stamp. It does not check a login. It ends with `exit`.

`CronController::autorunAction` disables the layout and has no other body.

`CronController::queingAction` loads a queue from `Model_AccreditationsApplications` (class **not in git**), builds a PDF via dompdf from `{scheme}://{host}/pdf/a6/id/{AAID}`, and when `AC == 14` POSTs the application to AppWiz. No `pdf` controller exists in this tree.

### WF-4 — Company registration

`Companies_RegisterController::indexAction` loads B-BBEE levels, company types (`Accre_Company_Types`), and countries for the form.

`newcompanyAction` on POST:

1. Rejects the registration if `RegistrationNumber` already exists on `Companies_Profile`, or if `IDNumber` or `Email` already exists on `Company_Members`. Both failures return JSON `OK` 2 and the same "account already exist" message.
2. Otherwise stores the password as `md5`, sets `Rol` to `Administrator`, `DateCreated` to today, `IDType` 1 when country is the string `SOUTH AFRICA` and 2 otherwise, and a random `Ref`.
3. Creates the company with a random `RefNumber`, `CreatedBy` / `AdminID` set to the new member.
4. Stores a `Validate_Key` and emails a link to `http://www.sarugby.online/companies/register/validateemail/id/{key}`.
5. Returns JSON `OK` 1 asking the person to open the email.

The email says an administrator will review the company before it can be activated, and that this can take up to 48 hours. It also says the person cannot sign up to an event until extra documents are supplied.

`validateemailAction` looks up `Validate_Key`. On match it clears the key and sets `Email_Confirmed` 1, `Email_Confirmed_Date` to today, and `AllowLogin` 1. The page then says the account is now active and links to `/companies/login`.

**Discrepancy:** the email describes an administrator hold of up to 48 hours. The confirmation action turns login on immediately. Which behaviour is the requirement is **Unknown / Requires Confirmation**.

A parallel path exists at `company/index/newcompany` and a form-only action at `default/company/registration`. They are not the same controller.

### WF-5 — Company sign-in and password reset

`Companies_LoginController::indexAction` redirects to `/companies/` when `Zend_Auth` already has an identity. On POST it authenticates `Email` and `password` against `CompaniesLogin`. The submitted password is `md5`'d first. A fixed string in this action, not repeated here, skips that hash and uses the password returned by `CompanyProfile::adminLogin` instead.

`LoginNow` writes the adapter row into the auth storage, updates `LastLogin`, and returns success. It also echoes the text `LOGIN` before the redirect.

`forgotAction` looks up `Company_Members` by `Email`, stores `ResetKey` as `md5` of a random id, and emails a link. The anchor href is `/companies/login/reset/user/{key}` on the current host, using `http://`. The visible link text is `/login/reset/user/{key}` (no `companies` segment). The greeting prints `FirstName` twice.

`resetAction` requires `user`. It loads `ResetKey`. On POST it stores `md5` of the new password, clears `ResetKey`, sets `AllowLogin` 1 and `Email_Confirmed` 1, and emails the new password in the message body.

`logoutAction` clears the identity, destroys the session, and redirects to `/companies/login`.

Controllers `companies` index, users, permits, events, and company profile redirect to `/companies/login/` when there is no identity.

`companies` index assigns `$this->_CID = 35` and does not read that property again. Counts on the home action use `$this->identity->CID`.

The login query does not filter `AllowLogin` in PHP. Whether the `CompaniesLogin` view does that filter is **Unknown / Requires Confirmation**.

### WF-6 — Company users

Signed-in user. `UsersController` lists members with `getAll` for the identity's `CID`.

`addAction` on POST inserts into `Company_Members` with that `CID`, today's date, and a random `Ref`. If `AllowLogin` is posted, it generates a random password, stores `md5`, and does not show a mail send in this action. Otherwise `AllowLogin` is 0. An optional base64 image is written to `public/archives/accreditation/{MID}_image.jpg`. Success JSON is `OK` 0. Failure JSON is `OK` 1. Those codes are the opposite of the registration action's `OK` 1 for success.

### WF-7 — Load-in / load-out permit

Signed-in user. `PermitsController::createAction` and `addpermitAction` use accreditation event id `27` (`getEvent(27)` and `$AC = 27`).

`addpermitAction` on POST:

- Builds an occupants list. Type `1` occupants are matched to `Company_Members` and their name, surname, and ID are replaced from that row.
- A driver, when a name is posted, is matched the same way and flagged `Driver` 1.
- Occupants are stored as JSON.
- `PermitNr` is first taken from `getRef(27)`, then after insert replaced with `PE` plus the new id left-padded to 6 digits.
- `PermitRefSerial` is a random 13-digit string.
- `Status` is set to `2`. The code does not name status `2`.
- `EID` is `27`. `MID` and `CID` come from the identity.
- An email is sent to the address on the permit row. The message says an administrator will review the request, quotes the permit number as `{PermitNr}/{year}`, says cancellations or changes may be submitted until 1pm and that next-day changes after that time will not be accepted, says load-out is first come first served, says administrators may change the requested date and time, and lists 14 health-and-safety documents required for work permits. The phrase "Loud-out" appears in that email.

No PHP in this action compares the clock to 1pm or rejects a late change. Provenance of the cutoff: **Implemented** as email copy. Enforcement in this controller: not found.

`savepermitAction` updates an existing permit id `pid` for the same session company. `getpermitpdfAction` lives on `Companies_IndexController`.

`EventsController::indexAction` calls `AccreditationList::getEvents(2)` and `print_r`s the rows. It does not render a finished events page.

### WF-8 — Catering menu

`menu/session/dates` lists food sessions for venue id `id` (`Tournament_Foodsessions`).

`menus` lists sessions for the same venue and date as the session id.

`menu` lists `SessionFood_Items` for that session. The view builds a QR image whose target is `https://www.sarugby.online/menu/session/menu/id/{VID}`.

`nutrition` loads `Nutritions` by id, JSON-decodes `Allergens`, and loads matching `Allergens` rows. Layout is disabled.

No login check was found on `Menu_SessionController`.

### WF-9 — Blue Card page

`bluecard/index` assigns:

- Incident causes: Tackle, Ball Carry, Ruck, Scrum, Line-out, Open play, Collision, Unknown.
- Roles: Referee, Coach, Team Manager, Physiotherapist, Parent, Guardian, Player.
- Positions 1–15 with the labels in the controller (for example "1 - Loose head prop").
- Unions from `ef_unions` and ethnicity from `Ethnicity`.
- A thank-you block whose reference number is `random_id(4)` generated while the page is built.

The form id is `example-basic`. Most inputs have no `name`. `indexAction` does not read POST and does not insert a row. Provenance: **Implemented** as a page. Persistence of a Blue Card is not in this controller.

### WF-10 — Default-module login (Footprint copy)

`default/login` is a separate flow from the companies portal.

- If `Zend_Auth` already has an identity, redirect to `/`.
- POST field `data[Email]` must match `co_credetials.Username` with `FPLogin = 1`, or the email is replaced with `uniqid()` before authenticate.
- Password is `md5`'d. A fixed string in this action, not repeated here, loads the stored password instead.
- Adapter table `co_credetials`, identity `Username`, credential `Password`.
- On success, `Model_Profile` is loaded by `UserID` and written to auth storage. `LastLogin` is updated. If `Status` is greater than 0, redirect to `/`. Otherwise redirect to `/first`. No `first` controller was found.
- If authentication fails and the credential row has an empty password, a reset key is stored and `sendResetLink` sends mail from `support@footprint.net` ("SARU E-Filing", subject "Footprint Credentails") with link `http://{host}/login/reset/user/{key}`.
- If there is no credential row but `profile.Email` matches, a credential row is inserted and the same mail is sent.

`Model_Credentials` is constructed in this controller and is not defined under `application/models/`. `registerAction` constructs `Model_DbTable_Countries`, which is also absent. `forgotAction` is empty.

Provenance: **Implemented** controller that cannot run as written without the missing classes. The product language in the mail is Footprint, not the companies-portal "SA Rugby Event Administration".

## Functional requirements

### FR-001 — Public tournament home

The default module home lists non-hidden tournaments for a year, split into featured, international (`Level` 1), national (`Level` 2), and provincial (`Level` 3), and into current, completed, and upcoming.

Who: public visitor. Trigger: `GET /` or `GET /` with `year`. Output: layout `default` with view assigns `Data` and `Other` when status is OK, plus results and upcoming fixtures.

Evidence: `IndexController::indexAction`. Implementation: **Implemented**. The level names are the PHP array keys, not a column label. Confidence: high for the grouping, medium that those names are the business names.

### FR-002 — Hidden tournaments stay off the public lists

Queries that build the home list, the ajax tournament export, and the latest-results view use `Hide != 1`.

Evidence: `IndexController`, `AjaxController` is different (`Updated` only), `Model_Tournament::ListTourn`, `Model_Fixture::fixturesResultsTest2`. Implementation: **Implemented**. Confidence: high.

### FR-003 — Latest results cache

Latest results are up to 8 rows from `GameResults_view` where `Hide != 1`, ordered by `FixtureDate` and `StartTime` descending, cached at `public/archives/tournaments/latest_result.json` when `check_results_all` finds newer rows.

Evidence: `Model_Fixture::fixturesResultsTest2`. Implementation: **Implemented**. Confidence: high.

### FR-004 — Player points on a tournament

For each score object on a match report, points added are:

`tries * T_Tries + Conversions * T_Conversion + Penalties * T_Penalties + DropGoals * T_Dropgoal + 22Metertries * T_22Mtry`.

The try count stored on the player adds `22Metertries` into `tries`. `PenaltyTry` is selected by `Model_Tournament::getTournySettings` and does not appear in this formula.

Evidence: `TournamentsController::stats`. Implementation: **Implemented**. Confidence: high for the formula in `stats`. Whether every public page uses `stats` is not separately proven.

### FR-005 — Company registration uniqueness

A new company is rejected when the registration number exists, or when the person's ID number or email exists.

Evidence: `Companies_RegisterController`. Implementation: **Implemented**. Confidence: high.

### FR-006 — Email confirmation turns on company login

A valid `Validate_Key` sets `Email_Confirmed`, the confirmation date, and `AllowLogin` to 1, and clears the key.

Evidence: `validateemailAction`. Implementation: **Implemented**. The 48-hour review sentence in the email is not applied here. Confidence: high for the code, low that the email sentence is still the rule.

### FR-007 — Company area requires a session

`companies` index, users, permits, events, and organisation profile redirect to `/companies/login/` when `Zend_Auth` has no identity.

Evidence: those controllers' `init` methods. Implementation: **Implemented**. Confidence: high.

`company` module index and `menu` session actions do not perform that check.

### FR-008 — Permit request for event 27

A signed-in company can post a permit tied to event id 27, status 2, with occupants JSON, a `PE` + zero-padded id number, and a notification email.

Evidence: `PermitsController`. Implementation: **Implemented** with hardcoded event id. Confidence: high for the code path. Whether event 27 is still the live event is **Unknown / Requires Confirmation**.

### FR-009 — Blue Card capture

The Blue Card screen collects concussion details in the browser and shows a reference number.

Evidence: `Bluecard_IndexController` and `bluecard/views/scripts/index/index.phtml`. Saving a record: **not implemented** in the action. Confidence: high.

### FR-010 — Menu by venue

A visitor with a venue or session id can open dates, menus, one menu, and a nutrition panel including allergens stored as a JSON list of ids.

Evidence: `Menu_SessionController`. Implementation: **Implemented**. Confidence: high, assuming the tables are populated. The database contents were not inspected.

## Constraints

- Time zone set in `public/index.php` and in `NewsController` and `TournamentsController`: `Africa/Johannesburg`.
- Default locale in `application.ini`: `af_ZA`. The default layout `lang` is `en-US`.
- Session cookie lifetime: `864000` seconds (10 days). **Inferred** from dividing the configured value by 86400.
- `APPLICATION_ENV` defaults to `production` when the environment variable is unset. In the `production` section, `display_errors` and `displayExceptions` are 1. The `development` and `testing` sections set them to 0.
- Passwords stored by the companies flow are `md5` hashes, not a slow password hash.
- `public/archives/` is gitignored (`public/archives/` in `.gitignore`). A fresh clone has no cache files. Home and tournament actions call `file_get_contents` on those paths.
- Random ids use `mcrypt_create_iv`. That extension is not part of modern PHP.

## Error behaviour

- Company registration wraps the insert in try/catch and returns JSON `OK` 0 with an apology message.
- Company login failure sets the view message "Bad entered credentials". Exceptions in `LoginNow` are printed.
- `validateemailAction` with an unknown key says the account may already be activated or the link does not exist.
- Missing `user` on company reset redirects to `/companies/login/`.
- Permit profile actions redirect to `/companies` when `getPermit` returns nothing.
- Default and module `ErrorController` classes exist. Their behaviour was not traced past the class list.
- Many actions `echo` and `exit` (ajax, cron, permit save, user add), so the layout and the error controller do not run.

## Integrations (product level)

| System | Direction | Trigger | Payload | Failure |
|---|---|---|---|---|
| MySQL `teamwizz_SARU` | App reads and writes | Almost every action | Rows listed in technical documentation | Zend exceptions; several actions print them |
| `public/archives` | App reads and writes | Home, ajax, tournaments, user photo, company photo | JSON and JPEG | `file_get_contents` warnings if the file is missing; not handled on the home action |
| `mail.sarugby.co.za` | Outbound SMTP | Register, forgot, reset, permit | HTML mail. Password not repeated | Catch blocks swallow the exception |
| AppWiz | Outbound POST | Cron queue item with `AC` 14 | Fields `G`, `A` (`acrSARugby`), `Barcode`, `Name`, `Surname`, `IDNo`, `Category`, `Zone`, `TheFile` | Curl result is stored in `$output` and not checked in the lines read |
| Google Charts | Browser GET of an image URL | Menu page render | QR target URL | Image fails in the browser if the API is gone. No server fallback |
| Footprint mail | Outbound, default login only | Reset link | HTML from `support@footprint.net` | Send failure is not assigned; no else branch |

Authentication to MySQL and SMTP is username and password in source. AppWiz POST in the lines read has no extra auth header beyond the form fields. The field `A` is the literal `acrSARugby`.
