# Business rules — crimemapping-webportal

Evidence date: 2026-10-18. Source: `master` at `f8a20d4`.

No business-rules document exists in the repository. Rules below are taken from code. Each rule is marked **Implemented** or **Inferred**. None of them is a signed-off requirement unless the row says the seed or the README states it.

## Identity and access

| ID | Rule | Status |
|---|---|---|
| BR-001 | Staff sign-in matches `users.email` and `password_verify` against `users.password`. Passwords are stored with `password_hash` / `PASSWORD_DEFAULT` | **Implemented** (`Welcome::index`, `UserModel::createUser`) |
| BR-002 | `users.active` is stored and shown as a badge. Sign-in does not read it. An inactive user with a valid password still receives a session | **Implemented** |
| BR-003 | A session is signed in when `isLoggedIn` is true. It holds `email` and `role_id` only. It does not hold the organisation id; later code loads the user by email | **Implemented** |
| BR-004 | A URI segment is allowed only when `user_menu.url` matches and `user_access` has that role and menu. Otherwise the user is sent to `/` or `blocked` | **Implemented** (`Filters/Authorization.php`) |
| BR-005 | `home`, the login URL, `register`, `blocked`, and every `api*` prefix skip the menu grant. The login filter also skips `users` | **Implemented** (`Config/Filters.php`) |
| BR-006 | Role id 1 is named Developer in the seed. The seed user is attached to organisation id 1 | **Implemented** (`Seeds/UserRole.php`, `Seeds/Users.php`) |
| BR-007 | Role id 4 sees users, crimes, the export query, and map incidents only where `organisation_id` equals their own. Every other role sees all organisations on those lists | **Implemented** |
| BR-008 | When role id 4 creates a user, the new role must be 2 and the organisation must match the creator. The error text calls role 2 "Member" | **Implemented** check. The word Member is **Inferred** as the role title |
| BR-009 | Updating a user does not repeat the role-2 / same-organisation check | **Implemented** (`Users::updateUser`) |
| BR-010 | Public registration sets `inputRole` to 1 before calling `createUser` | **Implemented**. Whether a public visitor should become a Developer is **Unknown / Requires Confirmation** |
| BR-011 | Creating a staff user rejects a duplicate `users.email`. Registration validates uniqueness against `users.username`, which is not a column on the users migration | **Implemented** |
| BR-012 | Phone validation looks up an exact `users.cellphone`. The profile textarea for notes has `maxlength="200"`. The database column is `TEXT` | **Implemented** |
| BR-013 | Menu category, menu, and submenu access are separate `user_access` rows. Helpers `check_menuCategory_access`, `check_menu_access`, and `check_submenu_access` read them | **Implemented** |

The seed file contains a named person's email, cellphone, and a short development password, and the organisation seed contains a short `api_key`. Those values are in source control. They are not repeated here. Whether that seed is applied outside a local database is **Unknown / Requires Confirmation**.

## Organisations and places

| ID | Rule | Status |
|---|---|---|
| BR-020 | An organisation has one parent id. `parent = 0` means no parent. Only those rows are offered as new children | **Implemented** |
| BR-021 | Deleting an organisation sets `active = 0`. It does not remove children or suburb links | **Implemented** |
| BR-022 | A suburb link is `org_location_map` between `organisations.id` and `locations.id`. Remove sets `active = 0` on that link. Add inserts a new row rather than reactivating the old one | **Implemented** |
| BR-023 | Suburb search treats `locations.mn_name` as the town and `locations.mp_name` as the suburb, using `LIKE` on the typed town | **Implemented**. The municipal meaning of `sp`, `mp`, `mn`, and `dc` is **Inferred** |
| BR-024 | The GIS "active towns" call groups `organisations.town` and does not use suburb links. A comment in `ApiGisModel` says suburb links cannot be required because not every organisation has an `org_location_map` row | **Implemented** |
| BR-025 | The seeded organisation is named `silicon overdrive`, town Cape Town, suburb Salt River, parent 0 | **Implemented** in `Seeds/Organisations.php` |

## Crimes and incidents

| ID | Rule | Status |
|---|---|---|
| BR-030 | A crime belongs to one `user_id` (the reporter) and one `organisation_id` | **Implemented** (`crime_log` insert) |
| BR-031 | A new staff crime stores the signed-in user as `created_by` and `updated_by`. Verified is 1 only when the checkbox is posted. Verified user and date are filled only in that case | **Implemented** (`CrimeLogModel::createCrimeLog`) |
| BR-032 | On update, if the stored `verified` flag is already 1, it stays 1 | **Implemented**. **Inferred** as a one-way verify rule. No comment states that this was requested |
| BR-033 | An empty report date is stored as `0000-00-00 00:00:00` | **Implemented** |
| BR-034 | Incident types on a crime are rows in `crime_incident_map`. Adding does not replace the existing set. Removal deletes one map row by id | **Implemented** |
| BR-035 | Deleting a crime deletes the crime row, its incident-map rows, and its `image_log` rows. Image files on disk are not deleted by this method | **Implemented** |
| BR-036 | A child crime is any `crime_log` whose `parent_id` equals the parent crime id. Candidates offered for linking are crimes whose report date contains the parent's calendar date | **Implemented** |
| BR-037 | API-created crimes start unverified (`verified = 0`), `parent_id = 0`, `active = 1`, and copy `user_id` into `created_by` | **Implemented** (`apiCrimes::create`) |
| BR-038 | API images must be jpeg, jpg, or png and at most 12,097,152 bytes. Images wider than 400 pixels are resampled. The directory is created mode `0777` | **Implemented** |
| BR-039 | Incident type "delete" sets `active = 0`. The map and the crime-log incident picker call `getIncidentTypes` with an attempted flag, not with `active` | **Implemented**. Inactive types can still be returned |
| BR-040 | Attempted filter value 2 means both attempted 0 and 1. The map UI uses sentinel `-1` for attempted and `0` for all, and those sentinels disable the per-type checkboxes | **Implemented** |
| BR-041 | Map incident labels use `incident_type.name_group`. The crime-log picker and the GIS feature use `incident_type.name` | **Implemented** |
| BR-042 | The export for roles other than 4 inner-joins incidents, so crimes without an incident are left out and crimes with several incidents repeat. Role 4's export query does not join incidents | **Implemented** |
| BR-043 | Notes on a crime are free text (`crime_log.note`). The export page prints them. The crime-log list does not show them in the table; the modal does | **Implemented** |
| BR-044 | Reverse geocoding from a map click is not active. Address is the posted string | **Implemented** (commented calls, commit `7e3d7b9`) |

## API authentication

| ID | Rule | Status |
|---|---|---|
| BR-050 | API login finds `users.cellphone` and checks the password. It returns a JWT and stores it on `users.token` | **Implemented** |
| BR-051 | The JWT payload expiry is `time() + 60000` seconds. The HMAC secret is the string default inside `JwtLibrary`, not an environment value | **Implemented** |
| BR-052 | Logout clears `users.token` for that bearer value. Later calls still accept any bearer that passes the signature and expiry check. They do not compare the bearer to `users.token` | **Implemented** (`is_jwt_valid` versus `logout`) |
| BR-053 | `POST apiusers` does not require a token. A duplicate cellphone is rejected | **Implemented** |
| BR-054 | New API users are role id 2, active 1, `created_by` 1 | **Implemented** |
| BR-055 | `GET apiorganisations` does not require a token and returns full rows, which include `api_key` when that column is present | **Implemented** |
| BR-056 | GIS authenticate treats `vendor` as `users.name` and `api_key` as the user's password | **Implemented** |
| BR-057 | Town crime pages are 100 rows. The town string is placed directly into the SQL comparison | **Implemented** |
| BR-058 | CORS allows any origin and the methods GET, POST, OPTIONS, PUT, DELETE | **Implemented** (`Filters/Cors.php`) |
| BR-059 | CSRF and honeypot filters are not applied | **Implemented** (commented in `Filters.php`) |

## Audit and activity

| ID | Rule | Status |
|---|---|---|
| BR-070 | `activity_log` is filled only when someone uses the activity-log screen. Sign-in, crime edits, and API calls do not insert into it | **Implemented** |
| BR-071 | Crime and organisation screens store `created_by` / `updated_by`. Incident types store the session role id in those columns, not the user id | **Implemented** |

## Rules that are not enforced

| Gap | What the code does instead |
|---|---|
| Staff menu grant on the API | API prefixes are excluded from both filters |
| Organisation boundary on a single crime id | `getCrimeLogs($id)` does not add the role-4 organisation predicate |
| Organisation boundary on API crime create | The posted `organisation_id` is stored as sent |
| One organisation per crime for images | The path uses the posted organisation id |
| Unique crime per place and time | No such check |
| Verified crimes cannot be deleted | Delete does not read `verified` |
| Registration closed in production | The route is public |

Whether any of those gaps is intentional is **Unknown / Requires Confirmation**.
