# PRD — findndrive_webportal

Evidence date: 2026-10-15. Source: `git@bitbucket.org:overdrive/findndrive_webportal.git`, `master` at `54b9de7` (2023-10-12, "database settings").

There is no prior PRD in the repository or in Silicon Ark. Requirements below were recovered from code, migrations, seeds, and deployment files. Provenance is marked on each item. Secret values are omitted.

## Product purpose

Find And Drive is a vehicle catalogue and affordability session store. Staff keep the catalogue and watch how many sessions apply or ask to fix credit. A separate client posts each step of a journey: location, price-range types, model, kilometres, a chosen car and tier, loan inputs, income and expenses, then apply or fix-my-credit.

The server does not price the loan. It saves the posted numbers and returns matching `car` rows, plus maintenance and service cost rows for one step.

Provenance: **Implemented**. `composer.json` description is "Find And Drive". `README.md` still describes an "Admin Overdrive Starter". Which name is the product name for customers is **Requires Confirmation**.

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Staff user | Signs in with username and password. Sees menus their role is granted | `Welcome`, `Authentication`, `Authorization` | Implemented |
| Developer | Seeded role id 1, name `Developer`. Public registration also inserts role `1` | `UserRole` seed, `Welcome::registration` | Implemented |
| Anonymous visitor | Can open `/`, `register`, and the `carapi` and `organisations` prefixes without a staff session | `Filters.php` except list | Implemented |
| Organisation API caller | Posts organisation name and `api_key`, receives a bearer JWT, then calls organisation CRUD | `Organisations::login` | Implemented |
| Simulator client | Calls `CarApi` step methods. JWT validation on those steps is assigned `true` in code | `CarApi` | Implemented |
| Seeded staff account | `Users` seeder inserts username `jaco@overdrive.co.za` on role 1. The plaintext password is a short numeric string and is not repeated here | `app/Database/Seeds/Users.php` | Implemented seed |

No other role names are seeded. Live role rows after the seed are **Unknown**.

## Main workflows

### W1 — Staff sign-in

Trigger: `GET /` or `POST GetLogin` → `Welcome::index`.

Inputs: `inputEmail`, `inputPassword`. Validation requires `inputEmail` only. Password length is not checked.

Behaviour:

1. If `isLoggedIn` is already true, redirect to `home`.
2. Look up `users` by username equal to the posted email.
3. Accept the password when `password_verify` matches the stored hash.
4. On success, session keys are `username`, `role`, and `isLoggedIn = TRUE`, then redirect to `home`.
5. Unknown user and wrong password share the flash text "Your ID or Password is Wrong".

Output: staff session. Provenance: **Implemented**.

`Authentication` sends any other URL that is not excluded back to `/` when `isLoggedIn` is not true.

### W2 — Public registration

Trigger: `GET register`, `POST register`.

Inputs: `inputFullname`, `inputEmail` (must be unique on `users.username`), `inputPassword`, `inputPassword2` (must match).

Behaviour: `UserModel::createUser` stores the email as username and hashes the password. `inputRole` is hard-coded to `1`.

Output: login view and a success flash. The new account is a Developer. Provenance: **Implemented**. Whether open registration is still wanted is **Requires Confirmation**.

### W3 — Menu authorisation

Trigger: any URL not in the `isGranted` except list, after login.

Behaviour (`Authorization`):

1. Take URI segment 1.
2. If no `user_menu.url` matches, redirect to `/`.
3. If the signed-in role has no `user_access` row for that menu id, redirect to `blocked`.

Excepted from the grant check: `/`, `getLogin`, `register`, `blocked`, `home`, `Welcome/*`, `organisations`, `organisations/*`, `chatGPT/*`, `carapi`, `carapi/*`.

`home` is excluded, so a signed-in user reaches the dashboard even when the Dashboard menu grant is missing. Provenance: **Implemented**.

Seeded menu titles: Dashboard (`home`), Users (`users`), Menu Management, CRUD Generator, Car Database (`car`), Data Files (`files`), Media Files (`media`). Categories: General, Settings, Developers.

### W4 — Staff dashboard

Trigger: `GET home`.

Behaviour (`Home::index`):

- Session count: all `app_session` rows.
- Active sessions: rows whose `created_at` date is today.
- Fix-credit percentage: distinct `app_id` groups where `fix_my_credit IS NOT NULL`, divided by all sessions, times 100.
- Applied count: groups where `apply IS NOT NULL`.
- Charts: last 7 days of session counts, and for each day the sums of applied, fix-my-credit, and "did not finish" (neither flag set).

`sendMail`, `salesforceConnect`, and `smsMessage` in this method are commented out.

Provenance: **Implemented**. The fix-credit write path uses the array key `fix_my_credit ` (trailing space) in `CarApi::step4_fix_my_credit`. The dashboard reads column `fix_my_credit`. Whether the flag is stored is **Requires Confirmation** against a live row.

### W5 — Vehicle catalogue and spreadsheet import

Trigger: `GET car` (list), `car/form/{id}` (edit), `GET car/carlist` (JSON for the table), `car/importSm/{file_id}` (default file id 1).

Import behaviour:

1. Load the uploaded file path for that id with PhpSpreadsheet.
2. Read rows from row 5. Columns A–BC map vehicle name, new price, type, category A service parts, category B wear parts, category C body parts, insurance, tiers A–D, petrol consumption, model name, and four tier image URLs.
3. Match an existing `car` on `name` + `car_type`. Update that row or insert.
4. Insert or skip cost-category A, B, and C rows for the car id.
5. Mark the uploaded file processed and redirect to `files`.

`CarModel::createCar` and `updateCar` persist name, price, type, insurance, and tier amounts. They do not persist `car_model`, `petrol_consumption`, or tier image paths, even though `importToCar` puts those values in the array. Provenance: **Implemented**. Image and model columns from the sheet are dropped on this path.

Tier image edit (`updatecarimage`) writes one of `tiera_image_path` … `tierd_image_path` when the tier letter is A, B, C, or D. A missing `://` in a stored path makes the form show `assets/img/placeholder-image.png`.

### W6 — Simulator session API

Auto-routing is on, so these methods are reachable as `carapi/{method}` in addition to the REST resource `carapi`. Staff login is not required. On every `step*` method, `$is_jwt_valid` is set to `true` and the real JWT check is commented out.

| Step | Method | What it stores | What it returns |
|---|---|---|---|
| Login probe | `step0_login` | Nothing | `true` or `false` after comparing username and password to two hardcoded accounts |
| Price range | `step1_range_car` | New `app_session`: `uniqid(app_id)`, country, country code, city, state, zip, ip | Distinct `car.car_type` values |
| Model | `step2_model` | `car_type`, `max_installment`, `car_type_list` | Models for the comma-separated type list, with price and tier image columns |
| Kilometres | `step2_model_km` | `model`, `km` | The posted payload. No catalogue query |
| Selection result | `step2_result` | same fields again | `car` rows for `car_model` |
| Loan screen | `step2_calculator` | `repayment`, `car_id`, `tier` | That car, plus `price` |
| Afford | `step3_affort` | `deposit`, `manage_credit`, `payment_term`, `balloon`, `est_repayment`, `interest_rate` | All cars |
| Results | `step4_results` | `car_id_first`, `net_salary`, `total_expenses`, `savings` | All cars |
| Maintenance | `step4_maintenance_and_services` | also `debt_expenses`, `living_expenses` | Latest `car_cost_category_a` row (called maintenance) and latest `car_cost_category_b` row (called services) for `car_id` |
| Recommend | `step4_recommend` | `car_type_first` | All cars |
| Confirm recommendation | `step4_recommend_confirm` | `car_model_first`, `car_type_recommended_list` | Models for that type list |
| Summary | `step4_summary` | `recommended_model` | Cars whose `car_model` equals `recommended_model` |
| Apply | `step4_apply` | `apply` | All cars |
| Congratulations | `step4_congratulations` | `apply` again | All cars |
| Fix credit | `step4_fix_my_credit` | key `fix_my_credit ` (trailing space) | All cars |

Updates match `app_session.session`. Empty catalogue results return 404 "No Data Found with Session".

`step0_login` accepts two usernames, `developerTest` and `absaTestUser`, with plaintext passwords in source. The password strings are omitted. Provenance: **Implemented**. Whether Absa is a partner is **Requires Confirmation**. The method does not create a session or a token.

`new_session` looks up an existing `session` string and, if found, returns a JWT for `name` expiring in 6000 seconds. It does not check a password.

`index` returns every `app_session` row and does not check a JWT. `show` and `create` do check a JWT.

Provenance: **Implemented**. The client that knows this sequence is **Unknown**.

### W7 — Organisation API

Documented in comments on `Routes.php` and implemented on `Organisations`:

- `POST organisations/login` with JSON `name` and `api_key`. A matching row returns `{token}` (HS256, expiry `time() + 6000`).
- `GET organisations` and `GET organisations/{id}` require that bearer token.
- `POST organisations` inserts name, address, contact number, town, suburb, parent, note, status, api_key, created_by, updated_by. Success text says "Employee created successfully".
- `PUT organisations/{id}` updates the same fields except created_by. Success text says "Employee updated successfully".
- `DELETE organisations/{id}` deletes the row. Success text says "Employee successfully deleted".

The seed inserts one organisation named `silicon overdrive` in Cape Town / Salt River with status `active`. Its `api_key` is a short numeric string and is not repeated here.

Provenance: **Implemented**. The word "Employee" in the messages does not match the table. **Inferred** leftover wording.

### W8 — Files, media, and starter tools

- `files` lists uploaded files. `POST files/upload` stores an upload. Import reads that path.
- `media` lists media files. Deactivate is `POST media/deactiveMediaFile`.
- `chatGPT` renders a page. `GET chatGPT/sent` posts the `message` query value to OpenAI completions. The bearer token is hardcoded in `chatgpt_helper.php` and is not repeated here. Usage is inserted into `chatgpt_log`.
- `MasterFile` lists rows that include taste notes and history, and can ask ChatGPT for tasting notes. That language is not vehicle data. Provenance: **Implemented**, domain **Requires Confirmation**.
- `SiteHealth` lists sites and a truncated HTTP response. No probe job was found in `app/`.
- `Test` uploads a `.side` file and can mark a project active. What a side project runs is local to `Test::runsideproject`.
- Developer screens: menu management, CRUD generator, database table view, Adminer views under `app/Views/projects/adminer.php`.

## Functional requirements

### FR-001 — Staff password login

The system accepts a username and password, verifies the password hash, and starts a session.

Evidence: `Welcome::index`, `UserModel::getUser`. Implementation: Implemented. Confidence: High.

### FR-002 — Registration creates a Developer

A successful registration inserts `users.role = 1`.

Evidence: `Welcome::registration`. Implementation: Implemented. Confidence: High. Product intent: **Requires Confirmation**.

### FR-003 — Menu URL grant

A signed-in user may open a first path segment only when that segment is a menu URL granted to their role, unless the segment is on the except list.

Evidence: `Authorization`, `Filters.php`. Implementation: Implemented. Confidence: High.

### FR-004 — Catalogue import from row 5

Given an uploaded spreadsheet id, the system upserts cars and writes three cost-category tables from a fixed column map.

Evidence: `Car::importSm`. Implementation: Implemented. Confidence: High. Dropped columns: see W5.

### FR-005 — Session step store

Each simulator step updates `app_session` by the `session` string and returns catalogue data or the posted payload.

Evidence: `CarApi`, `AppSessionModel::updateAppSessionSpesific`. Implementation: Implemented. Confidence: High.

### FR-006 — No server-side repayment formula

Interest, balloon, deposit, and estimated repayment are stored as posted strings. No formula was found in `app/`.

Evidence: `CarApi::step3_affort` and related methods. Implementation: Implemented. Confidence: High. Where the formula lives: **Unknown**.

### FR-007 — Dashboard counts

The dashboard shows total sessions, today's sessions, fix-credit percentage, applied count, and 7-day charts.

Evidence: `Home::index`, `AppSessionModel::getChartSession`, `getChartOutcome`. Implementation: Implemented. Confidence: High.

### FR-008 — Organisation JWT CRUD

Organisation login issues a 6000-second JWT. Later organisation verbs require it.

Evidence: `Organisations`, `JwtLibrary`. Implementation: Implemented. Confidence: High.

### FR-009 — Deploy on master

A commit to `master` packages the tree and runs CodeDeploy, then SonarQube.

Evidence: `bitbucket-pipelines.yml`. Implementation: Implemented as pipeline definition. Whether the pipeline is enabled in Bitbucket: **Unknown**.

## Integrations

| System | Purpose | Direction | Auth as found | Failure behaviour |
|---|---|---|---|---|
| MySQL | All product data | App → DB | Sample env uses `root` and an empty password. `Database.php` username, password, and database are empty strings. A Secrets Manager call is commented out | CI debug toolbar when not production |
| OpenAI completions | ChatGPT page and master-file tasting notes | App → `api.openai.com` | Bearer token hardcoded in `chatgpt_helper.php` | Returns the cURL error string |
| AWS Secrets Manager | `getSecret` / `createSecret` in `aws_helper.php` | App → `us-east-1` | AWS profile `default`. Not called by the active database config | Prints the AWS error message |
| AWS CodeDeploy + S3 | Production deploy | Pipeline → AWS | Bitbucket repository variables | Not described in repo |
| Salesforce Marketing Cloud | `salesforceConnect()` lists ET lists. Call sites in `Home`, `MasterFile`, and `SiteHealth` are commented out | App → Marketing Cloud | `config.php` holds client id, client secret, and a WSDL URL. Values omitted | `print_r` of the SDK response. No caller is active |
| Africa's Talking SMS | `smsMessage()` | App → SMS | Sandbox username and an API key are hardcoded. Values and the destination number are omitted. Call sites are commented out | Echoes the exception message |
| SMTP / CI email | `sendMail` from `php@overdrive.co.za` | App → mail | `Config\Email` was not given a product-specific transport in the files read beyond the helper | Prints the email debugger. Call sites commented out |
| SonarQube | Scan after deploy | Pipeline → Sonar | `SONAR_TOKEN` repository variable | Pipeline step failure. Not specified further |

`orhanerday/open-ai` is in `composer.json`. The ChatGPT helper uses raw cURL, not that package. Provenance: **Implemented**.

## Constraints

- PHP 8.0 and the extensions listed in the README (`intl`, `mbstring`, `mysqlnd`, `json`, `xml`; `libcurl` if HTTP is used).
- CSRF and honeypot filters are commented out in `Filters.php`.
- CORS allows origin `*` and answers `OPTIONS` by exiting.
- JWT HMAC secret defaults to a short hardcoded string inside `JwtLibrary`. It is not read from the environment. The string is omitted.
- Simulator steps do not enforce the JWT.
- `CarApi::index` lists every session without a token.
- Registration is public and grants the Developer role.
- Committed files contain live-looking credentials (OpenAI, Africa's Talking, Salesforce, sample encryption key, seed api key, seed password, simulator passwords). They are not copied into this PRD. **Requires Confirmation** that they have been rotated.

## Error and edge behaviour

| Case | Behaviour | Provenance |
|---|---|---|
| Bad staff password | Flash error, redirect to `/` | Implemented |
| Duplicate registration email | Validation error flash, register view | Implemented |
| Menu URL missing | Redirect to `/` | Implemented |
| Menu not granted | Redirect to `blocked` | Implemented |
| Simulator step with no catalogue rows | HTTP 404 text "No Data Found with Session …" | Implemented |
| Organisation id missing | 404 "No Data Found with id …" | Implemented |
| Bad organisation login | 404 "No user found" | Implemented |
| `step0_login` failure | JSON `false`, not an error status | Implemented |
| Missing JSON fields on a step | PHP notice or error on property access. No validation rules were found on `CarApi` | Inferred from the unguarded `$json->field` reads |
| Import file missing | PhpSpreadsheet load failure. No local catch in `importSm` | Inferred |
| Dashboard with zero sessions | Division by `count(getAppSession())`. An empty table would divide by zero | Inferred |

## Unknown / requires confirmation

- Customer-facing name, and whether Absa is a client (`absaTestUser`).
- The client application and who calculates repayments.
- Production base URL (`App::$baseURL` is an empty string).
- Whether `master` still deploys, and the CodeDeploy application name.
- Whether master-file taste notes, site health, side-file tests, Adminer, and CRUD generator are in scope or starter leftovers.
- Whether public registration and the forced-true JWT checks are accepted behaviour or defects.
- Live database contents after 2023-10-12.
