# PRD — crimemapping_website

Evidence date: 2026-10-18. Source: `git@bitbucket.org:overdrive/crimemapping_website.git`, `master` at `8c3c372f82a07c3b808cf75a64d2e2f49eca502a`.

Provenance labels: **Documented**, **Implemented**, **Inferred**, **Planned**, **Unknown**.

No formal PRD existed in the repository or in `silicon_ark/crimemapping_website/` before this file. Marketing copy in `index.html` is the only product narrative. Requirements below are taken from the implementation unless marked **Documented**. They are not an approved backlog.

## Product purpose

CrimeMapping, as written on the public page, digitises crime incidents so they can be mapped and analysed for patterns, trends, and hotspots, for law-enforcement resource decisions. The implemented system is an organisation-scoped crime log: staff record incidents in an admin portal or a mobile web app, and maps and a GIS API read that log.

Provenance of the marketing sentence: **Documented** (`index.html`). Provenance of the implemented system: **Inferred** from routes, schema, and screens. **Requires confirmation** as a product statement.

Names:

| Surface | Value | Evidence |
|---|---|---|
| Marketing title | Crimemapping / Comming soon | `index.html` `<title>` (spelling is in the file) |
| Marketing contact heading | Contact Us1 | `index.html` |
| Contact address | info@crimemapping.co.za | `index.html` |
| Admin brand | Crime Mapping | `app/Views/layouts/sidebar.php` |
| Admin package | `codeigniter4/adminstarter`, description "Admin Overdrive" | `admin.crimemapping.co.za/composer.json` |
| Mobile HTML title | Crime Mapping Org | `app.crimemapping.co.za/index.html` |
| Mobile manifest | Crime Mapping / Crime Mapping Mobile Application | `manifest.json` |
| Saved page title | Coming soon – Crime Mapping | `org/index.html` |

## Users and roles

| Actor | What the system does | Evidence | Provenance |
|---|---|---|---|
| Public visitor | Reads the marketing page. Nav links open the app and the admin login | `index.html` | **Implemented** |
| Admin user, signed out | `Welcome::index` shows the login view. A valid email and `password_verify` success sets session `email`, `role_id`, `isLoggedIn` and sends the browser to `home` | `Welcome.php` | **Implemented** |
| Admin user, signed in | Global filters `isLoggedIn` and `isGranted` run before most routes. Access is a row in `user_access` for the session role and the menu whose `url` is the first URI segment | `Filters.php`, `Authorization.php` | **Implemented** |
| Seeded Developer | Seeder inserts `user_role.id` 1, `role_name` `Developer`, and one user on organisation "silicon overdrive" | `Seeds/UserRole.php`, `Seeds/Users.php`, `Seeds/Organisations.php` | **Implemented** in seed data. Whether production still has this row is **Unknown** |
| Role id 4 | Crime-log list, user list, and crime-map query add `organisation_id` equal to the signed-in user's organisation. Every other role id receives the unfiltered list | `CrimeLogModel::getCrimeLogs`, `UserModel::getUser`, `CrimeMapModel::getCrimeMapIncidents` | **Implemented**. The role name for id 4 is **Unknown / Requires Confirmation** |
| Role id 1 on the crime-log modal | Delete button is always rendered. Other role ids get a button that the page script hides when the crime is verified | `crimeLogList.php` | **Implemented** in the browser. `CrimeLog::deleteCrimeLog` does not check role or `verified` |
| Mobile app user | Signs in with cellphone and password against `apiusers/login`. Routes include `/login`, `/signup`, `/forgot-password`, `/report-crime`, `/unauth` | built `main.f6fb2877.js` | **Implemented** in the bundle |
| GIS vendor | `apigis/authenticate` looks up `users.name` and checks `password_verify` against the JSON field `api_key` | `Apigis.php` | **Implemented**. It does not read `organisations.api_key` |
| Self-registration | `Welcome::registration` validates `is_unique[users.username]` and passes `inputRole` 1 | `Welcome.php` | **Implemented**. The `users` migration has `email`, not `username` |

Menu seed titles are Dashboard (`home`), Users (`users`), Menu Management, and CRUD Generator. Crime log, map, organisations, locations, incident types, and export are not in that seed. They are reachable because auto-routing is on (`Routes.php` `$routes->setAutoRoute(true)`) and because menus can be inserted at runtime. Which menus exist in production is **Unknown / Requires Confirmation**.

## Major workflows

### WF-1 — Marketing visit

Trigger: request for the site root.

Behaviour (**Implemented**, `index.html`, `.htaccess`, `email.php`):

1. `.htaccess` redirects HTTP to HTTPS.
2. The page explains crime mapping, lists advantages, and shows a "How does it work?" sequence: capturing app, incident database, GIS map, geo-spatial analysis.
3. "Go to App" opens `https://app.crimemapping.co.za/`. "Login" opens `https://admin.crimemapping.co.za/public/`.
4. Contact block shows `info@crimemapping.co.za`. A notify-me form is commented out. `email.php` still redirects to `https://crimemapping.co.za/#Contact?Success=True`.
5. Matomo records the view to `analytics.medmin.co.za` site id 4, cookie domain `*.crimemapping.co.za`.

Social icon links are `javascript:void(0)`.

### WF-2 — Admin sign-in

Trigger: POST of `inputEmail` and `inputPassword` to `/` (`Welcome::index`). The route file also declares POST `GetLogin`.

Inputs: email, password.

Behaviour:

1. If `isLoggedIn` is already true, redirect to `home`.
2. If `inputEmail` fails `required`, show the login view again.
3. Load `users` by email. On match, `password_verify`. On success, set the session keys above and redirect to `home`.
4. On unknown email or bad password, flash "Your ID or Password is Wrong" and redirect to `/`.

The handler does not check `users.active`. Provenance: **Implemented**.

`home` renders `common/home`, whose content section is empty. The sidebar still loads from menu tables. Provenance: **Implemented**.

Session driver in the committed `env` is the database handler, cookie `ci_session`, match IP true, regenerate-destroy true. `App.php` `$sessionExpiration` is 7200 seconds. Timezone in `App.php` is `America/Chicago`.

Logout is `Welcome::logout`, which destroys the session.

### WF-3 — Record a crime in the admin

Trigger: signed-in user opens `crimeLog` and submits the modal.

Inputs from `CrimeLog::createCrimeLog`: posted fields including organisation, report date, longitude, latitude, address, verified checkbox, active checkbox, note, and a comma-separated incident id string. Report date defaults to `0000-00-00 00:00:00` when empty. Verified and active default to 0 when the checkbox is absent.

Behaviour:

1. Insert `crime_log` with the signed-in user id as `created_by` and `updated_by`. If verified is checked, `verified_user_id` is that user and `verified_date` is now. Address is stored in `coordinates_address`.
2. Split `inputIncidentString` and insert one `crime_incident_map` row per id.
3. Flash success or failure and redirect to `crimeLog`.

Update (`updateCrimeLog`) writes the same crime fields. If the row is already `verified = 1`, the update keeps it verified. New incident ids are inserted. Existing map rows are not replaced by this loop. A child-id string sets `parent_id` on those crime rows to this crime id. Children offered for linking are other crimes whose `report_date` matches the same calendar date (`getCrimeLogsPotentialChildren`).

Delete removes the `crime_log` row and matching `crime_incident_map` and `image_log` rows. A commented block in the model would have set `active` to 0 instead. The live path is a hard delete. Provenance: **Implemented**.

The list for role id 4 is limited to that user's `organisation_id`. Other roles see every crime log.

### WF-4 — Map crimes

Trigger: `crimeMap` page load, then `getCrimeMapIncidents` and `getIncidentTypes`.

Inputs: start date, end date, incident-type id list, attempted flag. Default dates are the first and last day of the current month. Negative ids in the type list are stripped (`-1` means attempted, `0` means all). `attempted = 2` means both `incident_type.attempted` 0 and 1.

Behaviour: join `crime_incident_map`, `incident_type`, and `crime_log`. Role id 4 also filters `crime_log.organisation_id`. The controller collapses rows that share a crime id and concatenates distinct `name_group` values. The view loads Google Maps with the places library and a marker clusterer. Provenance: **Implemented**.

`crimeMapGis` renders a map with eight empty sidebar slots and a hidden input `user_email` set from `$_SESSION["email"]`. The commit that added it says the field is for Theo to retrieve. No server handler in this repository reads that input. Provenance: **Implemented** markup. What consumes it is **Unknown / Requires Confirmation**.

### WF-5 — Export the crime log

Trigger: `crimeLogExport` index.

Behaviour: the same crime query as the log, rendered by `crimeLogListExport.php`, which includes a `note` column and a DataTables setup with buttons copy, csv, excel, pdf, and print. Export is in the browser. Provenance: **Implemented**.

`CrimeLogExport::createCrimeLog` redirects to `crimeLog`, not `crimeLogExport`. Provenance: **Implemented**.

### WF-6 — Mobile sign-in and report

Trigger: the built app at `https://app.crimemapping.co.za`.

Routes extracted from `static/js/main.f6fb2877.js`: `/`, `/login`, `/signup`, `/forgot-password`, `/report-crime`, `/unauth`, and `*`.

API URLs hardcoded in that file:

- `https://admin.crimemapping.co.za/public/apiusers/login`
- `https://admin.crimemapping.co.za/public/apiusers/logout`
- `https://admin.crimemapping.co.za/public/apiusers/`
- `https://admin.crimemapping.co.za/public/apicrimes/create`
- `https://admin.crimemapping.co.za/public/apiincidents`
- `https://admin.crimemapping.co.za/public/apiorganisations`

`Apiusers::login` expects JSON `cellphone` and `password`. On `password_verify` success it builds a JWT (HMAC-SHA256, `exp` = now + 60000 seconds), stores it on `users.token`, and returns `id` and `token`. It does not check `users.active`. Unknown cellphone returns "No user found". Bad password returns "incorrect password".

`Apicrimes::create` requires a valid bearer JWT. It inserts a crime with `parent_id` 0, `verified` 0, `active` 1, and the posted user, organisation, coordinates, address, and note. `report_date` is split on a comma into a date and a time. Incident ids come from JSON form field `incidents`. Files in `files` are accepted when the extension is jpeg, jpg, or png and the size is at most 12097152 bytes. Images wider than 400 pixels are resampled to width 400. Files are stored under `assets/img/crime_images/{organisation_id}/{crime_id}/` (directory mode `0777`). The success response is HTTP 201. A failure array with status 401 is overwritten by that 201 response before return.

`ForgotPassword` and `ChangePassword` on `Apiusers` and `ApiIncidents` are empty methods. The React source at `overdrive/crimemapping` (`ForgotPassword.js`, `AuthContext.js`, `5927ab4`) calls `resetPassword` from auth context. That function is commented out and is not passed to the context, so the screen sets "Failed to reset password". It does not call these PHP methods. The password-length string belongs to signup validation, not to this screen.

Provenance of this workflow: **Implemented** in the bundle and the PHP. The React source is `git@bitbucket.org:overdrive/crimemapping.git`, documented in `silicon_ark/crimemapping/`. Whether this bundle was built from `5927ab4` is **Unknown / Requires Confirmation**.

### WF-7 — GIS town feed

Trigger: caller with a bearer token from `apigis/authenticate`.

1. Authenticate: JSON `vendor` matched to `users.name`, JSON `api_key` checked with `password_verify` against `users.password`. Success returns a JWT and stores it on that user row.
2. `getActiveTowns`: groups `organisations` by `town` and returns the town name and a count. A suburb query against `org_location_map` is commented out, with a comment that not every organisation has suburb rows.
3. `getUserLocation`: JSON `email`. Returns that user's organisation `town` and suburbs from `getOrganisationValidSuburbs`.
4. `getTownCrimeLog`: JSON `town` and optional `page` (default 1). Returns up to 100 features per page with point coordinates, report date, address, and incident type name, plus `total_rows` and `total_pages`. The page query joins incidents. The totals query counts `crime_log` rows and does not join incidents. Those two counts can differ. Provenance: **Implemented**.

### WF-8 — Organisation, user, and catalogue maintenance

Organisations: create, update, hard delete, child organisations, and suburbs stored through `org_location_map` (`Organisations.php`, `OrganisationsModel.php`). The organisation form no longer writes the single `suburb` column (those assignments are commented out).

Users: create requires `is_unique[users.email]`. Password is `password_hash`. List is organisation-scoped when role id is 4. Delete is a hard delete. `validatePhoneNumber` returns a row when `users.cellphone` matches. Profile update can replace the password. The profile page script, when not submitting, requires length 8 through 14 (the branch `length >= 15` is an error even though the message says "8 - 15"), one capital letter, one digit, and a limited character set. Cellphone length must be 9 to 12 characters in that script. The server create action does not apply those rules.

Incident types: create and update write `name`, `note`, and `active`. Delete sets `active` to 0. `getIncidentTypes` does not filter on `active`. It does filter `attempted`. Create does not write `attempted`, `name_group`, or `color`, which the map reads.

Locations: create and update write municipal boundary fields `sp_*`, `mp_*`, `mn_*`, `dc_*`, `shape_length`, `shape_area`, and `active`. The list query limits to 10,000 rows ordered by `mp_name`. There is no migration for `locations` in this repository.

Activity log: create, update, and hard delete exist. What writes a row during login or crime capture was not found in the controllers that were read. **Unknown** whether the screen is used.

Menu management can insert menu categories, menus, and submenus, and can write new controller and view files on the server (`MenuManagement.php`). The CRUD generator view prints controller and model source into the page. Provenance: **Implemented**.

## Integrations

| System | Direction | Trigger | Data | Auth | Failure |
|---|---|---|---|---|---|
| Google Maps JavaScript API | Outbound browser | Crime log map, crime map, mobile map | Map tiles, places library on the admin crime map | API key from env or the app build | The bundle contains "Failed to load Google Maps script, retrying". PHP does not handle a Maps failure |
| Matomo `analytics.medmin.co.za` | Outbound browser | Marketing page load | Page view, site id 4 | None in the snippet | Tracker is async |
| MySQL | Admin server | Admin and API calls that were read | Users, roles, menus, organisations, crimes, incidents, images, sessions | `env` sample: host `localhost`, database `adminoverdrive`, user `root`, empty password, driver MySQLi | CI4 default. Not customised in the controllers that were read |
| FTP via `git-ftp` | Pipeline to host | Push to `master` | Working tree | `$FTP_USERNAME`, `$FTP_PASSWORD`, `$FTP_HOST` | Pipeline script has no retry |
| SonarQube | Pipeline | Admin `bitbucket-pipelines.yml` on `master` | Source scan | `$SONAR_HOST_URL`, `$SONAR_TOKEN` | Root pipeline step is commented out |

AWS SDK, mPDF, and PhpSpreadsheet are Composer requirements. No use of those packages was found under `admin.crimemapping.co.za/app`. Provenance: **Implemented** dependency, **Unknown** runtime use.

## Constraints

- PHP `^8.0` and the extensions listed in the admin README: intl, json, mbstring, mysqlnd, xml, and libcurl if HTTP calls are used.
- Admin routes are auto-routed. Explicit routes cover the form posts and the five API resources.
- CORS filter allows origin `*`, the listed methods, and dies on `OPTIONS`. It is global.
- CSRF and honeypot filters are commented out.
- `isLoggedIn` does not apply to `/`, `users`, `getLogin`, `register`, and the five API prefixes. `isGranted` does not apply to those except it still applies to `users`, and it does not apply to `home`.
- JWT checks use `JwtLibrary` with a hardcoded HMAC secret argument, not an environment variable.
- Crime images are stored on the admin server's filesystem, not in object storage, in the code that was read.

## Functional requirements

### FR-001 — Public site

The site root serves the marketing page, forces HTTPS, and links to the app and the admin login.

Evidence: `index.html`, `.htaccess`. Implementation: **Implemented**. Confidence: High.

### FR-002 — Admin authentication

A person with a `users.email` and a password that verifies can open a session. Wrong credentials get one error flash. Active flag is not consulted.

Evidence: `Welcome.php`. Implementation: **Implemented**. Confidence: High.

### FR-003 — Menu authorisation

A signed-in role can open a first-segment URL only when `user_menu.url` matches and `user_access` grants that role. Otherwise the filter redirects to `blocked` or `/`.

Evidence: `Authorization.php`. Implementation: **Implemented**. Confidence: High.

### FR-004 — Crime log

A signed-in user can create, update, link same-day children, attach incident types, and delete a crime log. Role id 4 sees only their organisation.

Evidence: `CrimeLog.php`, `CrimeLogModel.php`. Implementation: **Implemented**. Confidence: High.

### FR-005 — Crime map

A signed-in user can filter incidents by month-default dates, incident type, and attempted flag, and plot them on Google Maps. Role id 4 is organisation-scoped.

Evidence: `CrimeMap.php`, `CrimeMapModel.php`, `crimeMap.php`. Implementation: **Implemented**. Confidence: High.

### FR-006 — Mobile report

A bearer token from cellphone login can create a crime with incidents and jpeg/png photos.

Evidence: `Apiusers.php`, `Apicrimes.php`, `main.f6fb2877.js`. Implementation: **Implemented**. Confidence: High for the PHP. Medium for every screen in the bundle, because the source is minified.

### FR-007 — GIS feed

A vendor JWT can list towns, resolve a user's organisation geography, and page town crimes as point features.

Evidence: `Apigis.php`, `ApiGisModel.php`. Implementation: **Implemented**. Confidence: High.

### FR-008 — Export

The export view can copy or download the on-screen crime table, including notes, through DataTables buttons.

Evidence: `crimeLogListExport.php`. Implementation: **Implemented**. Confidence: High.

## Discrepancies

- Migrations do not create columns and tables the current models use. See technical documentation.
- Registration targets `users.username` and role id 1. The users table in the migration uses `email`. The only seeded role name for id 1 is Developer.
- Delete restrictions for verified crimes exist in the crime-log page script and not in `deleteCrimeLog`.
- `Apicrimes::create` reports success after a branch that set an error payload.
- GIS login checks a user password, while `organisations.api_key` is a separate column.
- Composer packages for AWS, PDF, and spreadsheets have no application call site. Export is client-side.
- The mobile service worker registers `serviceWorker.js`. The committed file is `service-worker.js`.
- `org/index.html` contains the heading "Promoting informed strategies to manage crime" and also lorem ipsum under "Visibility".
