# PRD — wca_dms (Admin Overdrive)

Evidence date: 2026-09-29.

Source: `git@bitbucket.org:overdrive/wca_dms.git`, `main` at `9f51c26` (2025-09-01).

There is no prior PRD in Silicon Ark. `README.md` points at `PROJECT_RULES.md`, `CHANGELOG.md`, and `NewKnowledgeBase.md`. Those files are not in the repository. Requirements below are recovered from `README.md`, `overview.md`, routes, controllers, models, migrations, seeds, and views. Provenance is labelled.

Secrets found in source (SMTP password, OpenAI token, Slack webhook, encryption key, seed password, Shoprite contract id) are not reproduced here.

## Product purpose

**Documented.** `README.md` names the product "WCA DMS (Admin Overdrive)" and lists authentication, users, menus, reports, email, files, settings, monitoring, a REST API, and AI. `composer.json` description is "Admin Overdrive". Homepage field is `https://www.overdrive.co.za`.

**Implemented.** The runnable product is an authenticated admin shell. After login the home page is a dashboard view with zeroed counters. Sidebar entries come from the database. Settings, users, cron, and mail are backed by controllers. Retailer order screens, product screens, company screens, and the fault finder exist as views and as routes whose controller classes are missing.

**Unknown / Requires Confirmation:** whether WCA DMS is a customer-specific deployment of Admin Overdrive, a document-management product, or the EDI operations console whose views live under `app/Views/edioverdrive/`.

## Users and roles

Seeded in `app/Database/Seeds/UserRole.php`:

| Role id (insert order) | Name | Session behaviour in `Welcome::setCompanyUsersAndAdminSessionVars` |
|---|---|---|
| 1 | Developer | `companyAccessIDs` and store access set to `all`. Comment in code: "Silicon internal roles" |
| 2 | Admin | Same as Developer |
| 3 | Company Admin | Company ids loaded from `company_users` joined to `companies` where the link is active |
| 4 | Company User | No branch. Company session arrays are not set |

Provenance: role names are **Implemented** by the seeder. The id numbers assume the seeder runs on an empty `user_role` table and are **Inferred** from insert order. Role 4 handling is **Implemented** as an omission.

`Users` seeder inserts one active user, full name "Developer Tester", username `admin@overdrive.co.za`, role 1. The password is committed. **Documented** again in `README.md` as the local test account. Do not treat that password as a production credential.

Other actors:

| Actor | Evidence | Status |
|---|---|---|
| Person who is not logged in | `Authentication` filter redirects to `/` | **Implemented** |
| Person whose IP is on `blocked_users_list` with `active = 1` | `Welcome` constructor sets session `userBlocked` | **Implemented** flag. What the login view does with that flag was not fully traced |
| Person with `users.active = 0` | Login, reset request, and reset submit refuse with "access has been revoked" | **Implemented** |
| Cron caller | `GET cronJobs`, excluded from login and grant filters | **Implemented** |
| Email pixel requester | `GET tracking-pixel`, excluded from login | **Implemented**, but `EmailPixelTrackingModel` is missing |
| Slack caller | `GET slack` excluded from both filters | **Implemented** route to `Slack::sendMessage` |
| API client for organisations | Comment in `Routes.php` describes bearer-token CRUD on `/organisations` | **Documented** in comments. Controller class `Organisations` is absent. `ApiSimulation` is a different JWT resource and is not routed |
| Deploy / scan operator | Bitbucket Pipelines SonarQube step | **Implemented** pipeline. No deploy step |

Self-registration is refused by an empty `Welcome::register()` with the comment "We don't do registrations on this application". `Welcome::registration()` validates uniqueness and matching passwords, flashes success, and returns the login view without inserting a user. Provenance: **Implemented**.

## Systems the product talks to

1. The operator's browser.
2. MySQL database `wca_dms` (name from `env`).
3. SMTP `smtp.overdriveqa.co.za`, port 587, from `info@overdriveqa.co.za`, display name "Admin Overdrive Notifications" plus `base_url()`. BCC `admin@overdrive.co.za`. Protocol in `app/Config/Email.php` is `smtp`.
4. OpenAI `https://api.openai.com/v1/completions`, model `text-davinci-003`, from `chatGPTMessage()`.
5. Slack incoming webhook, URL hardcoded in `Slack::sendMessage`.
6. Shoprite HTTP API, Basic auth, XML accept, hardcoded `ContractID` header, SSL verification off. Saves bodies under `writable/company_orders_xml/shoprite/`.
7. Pick n Pay SOAP PullService. Default WSDL `https://test.pnpportal.co.za/ediservices_test/wsdl/PullService.wsdl` and endpoint `https://uat.pnpportal.co.za/ediServices/pullservice.svc`. If the method's `api_endpoint` equals `https://www.pnpportal.co.za/ediServices/pullservice.svc`, the production WSDL and endpoint are used. Saves bodies under `writable/company_orders_xml/pnp/`.
8. Host metrics via `top -b -n 1` in the `monitor:system` command (Linux `top` and `posix_kill`).

Salesforce helper `salesforceConnect()` is an empty function with commented FuelSDK lines. Africa's Talking helper `smsMessage()` is entirely commented, including a sandbox key that is not copied here. AWS, mPDF, and PhpSpreadsheet have no call sites. Google Maps and SendGrid appear only as commented keys in `env`.

## Major workflows

### WF-1 — Sign in

`GET /` renders `common/login` when validation of `inputEmail` fails (including a normal first visit). `POST GetLogin` is also mapped to `Welcome::index`.

If `isLoggedIn` is already set, the user is sent to `home`.

Otherwise the controller loads the user by username, then:

1. If `active` is 0, flash "access has been revoked" and return to `/`.
2. If `password_verify` succeeds, call `check2faEmailConfirmationDate`.
3. If that returns valid, set session `username`, `role`, `isLoggedIn`, `userID`, empty `labelID`, set company session vars, write login history status 2, and redirect to `redirectURL` or `home`.
4. If 2FA is not valid, mail the code with subject "2FA Validation" and redirect to `2fa-validation`.
5. Wrong password: history status 1, reason "Wrong Password".
6. No username on the row: history status 1, reason "User does not exist".

`getUser()` returns `null` when no row matches (`getRowArray()`). The active check reads `$user['active']` before the username check. A missing user therefore hits that read first. Provenance: **Implemented**. Outcome on PHP 8 for a null row is **Inferred** as a runtime warning or error, not the "user does not exist" flash.

### WF-2 — Email two-factor

`UserModel::check2faEmailConfirmationDate`:

- No `email_authentication_codes` row, or row `status` is not 1: generate a new 6-digit code and return not valid.
- Row `status` is 1 and `updated_at` is 1 day or more before now (`$variableDays = 1`): generate a new code.
- Row `status` is 1 and fewer than 1 day has passed: skip 2FA.

`POST ConfirmCode` checks the code against `new_code` where `active = 1`. A match sets `status` to 1. The method then runs the login and 2FA date check again. Empty result: history status 4, "Incorrect validation code".

Provenance: **Implemented**. The comment in the model says "e.g., 10 days"; the assigned value is 1. The comment and the code disagree.

### WF-3 — Password reset

`GET forgot-password` shows the form. `POST sendResetPasswordLink` looks up the user.

- Inactive: revoked message.
- User exists: 32-byte hex token stored in `password_reset`, mail contains `reset-password?token=...&email=...`, history status 6, success flash names the address.
- User does not exist: history status 6 with reason "non-existing user", but the flash still says the link was sent to that address.

`GET reset-password` checks the token. Spaces in the email query value are replaced with `+`. Invalid token: history status 5.

`POST resetPassword` reads token and email by splitting the previous URL on `=`, not from the POST body. Passwords must match. Length must be at least 8 characters. On success it calls `updateUser` and redirects to `/` with no success flash. There is no migration for `password_reset` in this repository.

Provenance: **Implemented**. The table is **Inferred** as required and absent from migrations.

### WF-4 — Authorised navigation

After login, `isLoggedIn` must be true except for the paths listed in `Filters.php` (`/`, register, organisations, tracking pixel, cronJobs, store-api, 2FA, password reset, slack, and a few others).

`isGranted` loads a menu row by the first URL segment. No menu row: redirect to `/`. Menu row but `checkUserAccess` fails for the session role: redirect to `blocked` (`Welcome::forbiddenPage`).

`home`, `blocked`, and the auth paths are excluded from `isGranted`, so the dashboard does not need a menu grant check at the filter. The seed still inserts a Dashboard menu pointing at `home`.

CSRF and honeypot globals are commented out.

Provenance: **Implemented**.

### WF-5 — Company scope

Roles 1 and 2 receive all companies and all stores in session.

Role 3 loads active `company_users` rows and stores company ids. Store access is still `all`.

Role 4 is not handled.

`company_users` and `companies` are queried. Neither table has a migration here.

Provenance: **Implemented** session logic. Tables are **Unknown** relative to this repo's migrations.

### WF-6 — System settings

`GET system_settings` renders `systemSettings` with cron actions, the `settings` row named `email_schedule`, and `email_template_types`.

Tabs in the view: General Settings, Cron Settings, Email Settings, Email Logs, Email Templates, Login History, Blocked List, and a "Trigger CronEngine" button.

Routes also call `getCompaniesToProcessForFaults`, `changeErrorCheck`, `getTransportMethods`, `changeTransportMethodActive`, `getFaultTypes`, and `updateOrCreateFaultType`. Those methods are not on `SystemSettings`. `SystemSettingsModel` does query `company_transport_methods`.

Provenance: tabs and the methods that exist are **Implemented**. The extra routes are **Implemented** as broken routes.

### WF-7 — Reports

`Reports::index` returns `reports/reportsList`. Tabs: Uninvoiced Orders, Unmatched Invoices, Sales Reports, and BI Reporting when session role is 1 or 2.

The view scripts request URLs such as `reports/uninvoicedOrders`, `reports/unmatchedInvoices`, `reports/getDataForBiChart`, and several chart endpoints. The only reports route in `Routes.php` is a comment: `// $routes->get('reports/uninvoicedOrders', 'Reports::uninvoicedOrders');`. `Reports` has no other public methods. It imports `InvoicesModel`, `OrdersModel`, and `ProductsModel`, which are not in the tree. `use` does not load them until the class is referenced, so `index` can render.

Provenance: **Implemented** shell. Data APIs are absent. **Unknown / Requires Confirmation** whether they live in another repository.

### WF-8 — Cron

`GET cronJobs` is public.

1. Exit immediately on Saturday or Sunday (`date('N') >= 6`) or when the hour is before 08:00 or 23:00 or later. A comment says the end hour was changed from 17:00 to 23:00 by Wessels.
2. If `CI_ENVIRONMENT` is `production`, require query `key` to equal `CronJobs::SECURITY_KEY`. That constant is still the placeholder text telling the reader to add a key.
3. Load `cronjob_actions` where `active = 1`, ordered by `order`.
4. Run the row's `function` on this controller when the method exists and `checkCronTiming` says the recurrence (minutes) has elapsed since the latest log. Query `override=true` forces a run. Query `action` limits the run to that action name.
5. Recurrence 0 never runs.

Methods that exist: `sendScheduledEmails`, `getAllUnsentEmails`, `sendEmailsViaCron`, `checkServerLogs`. `sendScheduledEmails` sends `sent_queue` rows with `processed = 0` and a non-null `send_date` that is due, limited by `items_process_total` or 10. `checkServerLogs` looks at the last 5 minutes of monitor logs and, if any resource name appears more than 55 times, emails contact id 2, name "Wessels".

Public holidays are stored and edited in settings. `CronJobs::index` does not read `public_holidays`.

Provenance: **Implemented**. Holiday skip is **not** implemented in the cron entry point.

### WF-9 — Retailer order pull

`Rest::processRestRequest` GETs an endpoint with Basic auth and saves Shoprite order XML.

`Rest::processRestOrderConfirmation` PUTs an `ArrayOfLong` of order numbers to `endpoint?action=a`.

`Soap::processSoapRequest` sends `GetMessageRequest` for message type `Order`, `acceptBatch` Y, for a GLN.

`Soap::processSoapOrderConfirmation` sends `ConfirmMessageRequest`.

No other PHP file calls these methods. Provenance: **Implemented** clients, **Unknown** trigger.

### WF-10 — CSV upload of products or stores

`POST csv/upload` stores the file under `writable/uploads` and maps headers for upload type `Upload-Products` or `Upload-Stores`. `POST csv/save` writes rows with `company_id` from session `companyID` through `ProductsModel` or `CompanyStoresModel`. Both models are missing, so save cannot run.

Provenance: **Implemented** controller, missing models.

## Functional requirements

### FR-001 — Authenticated admin shell

The operator signs in with email and password and reaches a dashboard. Menu links are granted per role.

- Trigger: `GET /` then successful `Welcome::index`.
- Output: redirect to `home`, session flags set.
- Evidence: `Welcome.php`, `Home.php`, `Filters.php`, `UserMenu` seed.
- Implementation: **Implemented** for the seeded menu, subject to the null-user path in WF-1.
- Confidence: High for the happy path.

### FR-002 — Email 2FA at least daily

A successful password check still requires a fresh email code when the last confirmed code is a day old or missing.

- Evidence: `UserModel::check2faEmailConfirmationDate`.
- Implementation: **Implemented**.
- Confidence: High. The "10 days" comment is not the rule.

### FR-003 — Lock out repeated failures

Statuses other than 2 (login) and 3 (logout) count failures. Five failures for the same email, IP, status, and `active = 1` inside five minutes insert a row into `blocked_users_list`.

- Evidence: `LoginHistoryModel::checkFailures`. Comment says "TO-DO: Make this 5" while `$attemptsLimit` is already 5.
- The model property `$table` is `blockedusers`. Queries use `blocked_users_list`, which matches the migration.
- Implementation: **Implemented**.
- Confidence: High on the threshold. Whether the login page honours `userBlocked` before attempting a password is not fully confirmed.

### FR-004 — Revoke inactive users

`users.active = 0` cannot sign in or reset a password.

- Evidence: `Welcome.php`.
- Implementation: **Implemented** when a user row is returned.
- Confidence: High.

### FR-005 — Role menu grants

Changing category, menu, or submenu permission inserts or deletes `user_access` rows. The authorization filter checks the first URL segment against `user_access` for the session role.

- Evidence: `Users.php`, `Authorization.php`, `useraccess_helper.php`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-006 — Internal roles see every company

Roles 1 and 2 get `companyAccessIDs = ['all']`. Role 3 is limited to linked companies. Role 4 is unspecified in code.

- Evidence: `Welcome::setCompanyUsersAndAdminSessionVars`.
- Implementation: **Implemented** for 1–3. Role 4 is **Unknown**.
- Confidence: High for 1–3.

### FR-007 — No public registration

The register action does not create an account.

- Evidence: `Welcome::register`, `Welcome::registration`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-008 — Password minimum length on reset

Reset rejects passwords shorter than 8 characters. No other complexity rule is in `resetPassword`.

- Evidence: `Welcome::resetPassword`.
- Implementation: **Implemented** on that action only.
- Confidence: High.

### FR-009 — Settings for cron, mail, holidays, and blocks

An authorised user can list and edit cron actions, email templates, general `settings` rows, public holidays, and blocked-list active flags, and can read email logs and login history.

- Evidence: `SystemSettings.php`, `app/Views/systemSettings.php`.
- Implementation: **Implemented** for the methods that exist.
- Confidence: Medium. Fault-type and transport routes on the same controller are not implemented.

### FR-010 — Scheduled email dispatch

Due rows in `sent_queue` are sent by the cron entry point and then updated.

- Evidence: `CronJobs::sendScheduledEmails`.
- Implementation: **Implemented**, only if a `cronjob_actions` row points at that method and the weekday window allows the HTTP call.
- Confidence: High for the method. Whether such a row is seeded: it is not in the seeders found.

### FR-011 — Host alert email

If one monitored resource is logged more than 55 times in five minutes, cron emails a fixed contact.

- Evidence: `CronJobs::checkServerLogs`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-012 — Retailer EDI pull and confirm

The system can pull Pick n Pay orders over SOAP and Shoprite orders over REST, save the XML, and send confirmations.

- Evidence: `Soap.php`, `Rest.php`.
- Implementation: client methods **Implemented**. Invocation **Unknown**.
- Confidence: High that the methods exist. Low that any screen runs them from this repo.

### FR-013 — Order, invoice, product, company, and fault-finder operations

Screens and routes describe company vendor numbers, products, orders, invoices, label statuses, meeting groups, and fault processing for orders, invoices, and products.

- Evidence: `Routes.php`, `app/Views/edioverdrive/`, `app/Views/reports/`.
- Implementation: views and routes **present**. Controller classes **absent**. This is not a confirmed working feature.
- Confidence: High that the classes are missing.

### FR-014 — Reports for orders and invoices

The reports page offers uninvoiced orders, unmatched invoices, sales charts, and BI charts for roles 1 and 2.

- Evidence: `reportsList.php` and tab scripts.
- Implementation: page shell **Implemented**. Endpoints **not routed**.
- Confidence: High.

### FR-015 — CSV product and store import

A user can upload a CSV and map columns onto products or stores for the session company.

- Evidence: `Csv.php`.
- Implementation: **Blocked** by missing models.
- Confidence: High.

### FR-016 — ChatGPT prompt page

`GET chatGPT` shows a page. `GET chatGPT/sent` returns JSON from the OpenAI completions API. The grant filter excludes `chatGPT/*`. The login filter does not exclude it, so a session is still required.

- Evidence: `ChatGPT.php`, `chatgpt_helper.php`, `Filters.php`.
- Implementation: **Implemented**. Model `text-davinci-003` is a retired API shape; whether it still succeeds is **Unknown**.
- Confidence: High that the call is coded.

### FR-017 — Email open pixel

`GET tracking-pixel` and `GET Tracking-pixel` return a 1×1 PNG. If the visitor is not logged in, the code query is passed to `EmailPixelTrackingModel::logEmailPixel`. That class file is not in the repository, so the log call cannot succeed.

- Evidence: `EmailPixelTracking.php`.
- Implementation: response **Implemented**, persistence **not present**.
- Confidence: High.

### FR-018 — Organisations API

Route comments specify login at `POST /organisations/login` and CRUD under `/organisations` with a bearer token. `$routes->resource('organisations')` expects controller `Organisations`, which is absent. `OrganisationModel` exists and allows fields `name`, `address`, `contact_number`, `town`, `suburb`, `parent`, `note`, `status`, `api_key`, `created_by`, `updated_by`.

- Evidence: `Routes.php`, `OrganisationModel.php`.
- Implementation: **Not implemented** as a controller. Model and migration `organisations` exist.
- Confidence: High.

### FR-019 — Developer menu and CRUD generator

A developer can create menu categories, menus, and submenus, and can open a CRUD generator for a table that exists in the connected database.

- Evidence: `Developers/MenuManagement.php`, `Developers/CRUDGenerator.php`.
- Implementation: **Implemented** against whatever database is connected. Generated output was not executed in this investigation.
- Confidence: Medium.

## Important constraints

- PHP version is inconsistent: Composer `^8.0`, README 8.3. **Requires Confirmation** which version production runs.
- `app.baseURL` in `App.php` is an empty string. README says to set it in `.env`. The `env` template in the repo does not set `app.baseURL`.
- Sessions use the database handler, cookie `ci_session`, save path `sessions`, match IP true, regenerate destroy true (`env`).
- Cron does not run on weekends or outside 08:00–22:59 server local time (`date()` with no timezone set in the controller).
- Production cron requires a key that is still a placeholder.
- Auto-routing is off.
- SonarQube coverage is disabled and large parts of `app/` are excluded from analysis (`sonar-project.properties`).
- README claims MySQL row-level security. No policy, grant, or RLS statement exists outside that sentence.
- `overview.md` says the database is "likely" MySQL and PHP `^8.0`, and treats AWS, PDF, Excel, and AI as likely features because of Composer packages. Those sentences are weaker than the code. Prefer the code and `README.md` where they disagree.

## Discrepancies

| Topic | Document or route | Code |
|---|---|---|
| Product name | README "WCA DMS (Admin Overdrive)" | UI and mail say Admin Overdrive |
| PHP | README 8.3 | Composer `^8.0` |
| RLS | README | Not in code |
| AWS, PDF, Excel | README and overview | Packages only |
| 2FA interval | Model comment "10 days" | `$variableDays = 1` |
| Registrations | `registration()` success flash | No insert; `register()` empty |
| Reports data | View URLs and a commented route | No methods |
| EDI screens | Routes and `edioverdrive` views | Controllers missing |
| Project rules and changelog | README links | Files absent |
| Blocked users table property | Model `$table = 'blockedusers'` | Queries and migration use `blocked_users_list` |
| Password-reset flash for unknown email | User told a link was sent | No mail is sent |
