# Business rules — crimemapping_website

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

Rules below are what the code enforces or what the marketing page states. **Inferred** means the behaviour is visible in code and no comment or document states it as policy. Do not treat an inference as an approved rule.

## Marketing statements

These sentences are on `index.html`. Provenance: **Documented**. The application does not test them.

- Crime mapping is the digitisation, mapping, and analysis of crime incidents, used to identify patterns and trends and highlight hotspots.
- It is described as a tool for law-enforcement management decisions, resource targeting, and crime-prevention strategies.
- Listed advantages include accurate spatial data, statistical analysis, pattern knowledge, focused allocation, GIS reporting without the organisation operating a GIS, and a product that is inexpensive and simple.
- The "how it works" sequence is a capturing app, a digital incident database, a GIS map, then geo-spatial and statistical analysis.

`org/index.html` says "Promoting informed strategies to manage crime" and "Real Time Tracker". The Visibility block is lorem ipsum. Provenance: **Documented** in that saved page. It conflicts with treating `org/` as product copy. **Requires confirmation** which page is public.

## Identity and access

| ID | Rule | Evidence | Provenance |
|---|---|---|---|
| BR-001 | Admin login matches `users.email` and `password_verify`. The session stores `email`, `role_id`, and `isLoggedIn` | `Welcome::index` | **Implemented** |
| BR-002 | `users.active` is not read by admin login or by `Apiusers::login` | those methods | **Inferred** from absence |
| BR-003 | A first URL segment is allowed when `user_menu.url` equals that segment and `user_access` has the session `role_id` and that menu id. Missing menu redirects to `/`. Failed access redirects to `blocked` | `Authorization.php` | **Implemented** |
| BR-004 | `home` skips the grant filter. The login filter still applies | `Filters.php` | **Implemented** |
| BR-005 | The login filter does not run for the URI `users`. The grant filter still runs for `users` | `Filters.php` | **Implemented**. Whether skipping the login filter was intentional is **Unknown / Requires Confirmation** |
| BR-006 | API route prefixes `apiorganisations`, `apiusers`, `apicrimes`, `apiincidents`, and `apigis` skip both session filters | `Filters.php` | **Implemented** |
| BR-007 | Seed data inserts one role, `Developer`, at id 1 | `Seeds/UserRole.php` | **Implemented** seed. Production role names are **Unknown** |
| BR-008 | Role id 4 limits crime logs, users, and map incidents to `users.organisation_id`. Any other role id, including 1, does not add that filter | `CrimeLogModel`, `UserModel`, `CrimeMapModel` | **Inferred**. The name of role 4 is **Unknown / Requires Confirmation** |
| BR-009 | Self-registration stores `inputRole` 1 | `Welcome::registration` | **Implemented**. It validates `users.username`, which is not a column in the users migration |
| BR-010 | A new admin user must have an email that is not already in `users.email` | `Users::createUser` | **Implemented** |
| BR-011 | Admin user delete is a hard delete of the `users` row. No role check was found in `deleteUser` | `UserModel::deleteUser` | **Implemented** |
| BR-012 | Mobile login matches `users.cellphone` and `password_verify`, then stores a JWT on `users.token` | `Apiusers::login` | **Implemented** |
| BR-013 | JWT `exp` is `time() + 60000` seconds. The HMAC secret is a default argument in `JwtLibrary`, not an environment value | `Apiusers::login`, `JwtLibrary.php` | **Implemented** |
| BR-014 | GIS authenticate matches `users.name` to JSON `vendor` and `password_verify` of JSON `api_key` against `users.password` | `Apigis::authenticate` | **Implemented**. `organisations.api_key` is a different column and is not used here |
| BR-015 | `ApiOrganisations::index` returns all organisations with no bearer check. Show, create, update, and delete on that controller check the JWT | `ApiOrganisations.php` | **Implemented** |

## Crime log

| ID | Rule | Evidence | Provenance |
|---|---|---|---|
| BR-016 | A new admin crime stores organisation, report date, longitude, latitude, address, note, verified, and active. `user_id` on create is the posted `userID`. `created_by` is the signed-in user | `CrimeLogModel::createCrimeLog` | **Implemented** |
| BR-017 | Empty report date is stored as `0000-00-00 00:00:00` | `CrimeLog::createCrimeLog` | **Implemented** |
| BR-018 | Checking verified sets `verified` 1, `verified_user_id` to the editor, and `verified_date` to now. If the row is already verified, update keeps `verified` at 1 even when the checkbox is off | `CrimeLogModel::updateCrimeLog` | **Inferred** from the branch order |
| BR-019 | Incident ids posted as a comma-separated string each become a `crime_incident_map` row. Update adds rows. It does not delete ids omitted from the string. Removal is a separate `removeIncidentFromCrime` call | `CrimeLog.php`, `CrimeLogModel.php` | **Implemented** |
| BR-020 | A child crime is another `crime_log` row whose `parent_id` is set to the parent id. Candidates are crimes whose report date starts with the same calendar day | `getCrimeLogsPotentialChildren`, `addChildToCrimeLog` | **Inferred** |
| BR-021 | Unlinking a child sets `parent_id` to 0. It does not delete the child | `removeChildFromCrimeLog` | **Implemented** |
| BR-022 | Delete removes the crime row, its incident-map rows, and its image-log rows. The model contains a commented update that would have set `active` to 0 | `CrimeLogModel::deleteCrimeLog` | **Implemented** |
| BR-023 | In the crime-log modal, role id 1 always has a delete button. Any other role's delete button is hidden when `verified` is 1 and shown when it is not | `crimeLogList.php` | **Implemented** in the browser only |
| BR-024 | The delete endpoint does not check role id or `verified` | `CrimeLog::deleteCrimeLog` | **Implemented**. This disagrees with BR-023. **Requires confirmation** which is the rule |
| BR-025 | API-created crimes are stored unverified, active, `parent_id` 0. `report_date` must contain a comma between a date and a time | `Apicrimes::create` | **Implemented** |
| BR-026 | API images must use extension jpeg, jpg, or png and be at most 12097152 bytes. Width above 400 pixels is reduced to 400. The directory is created mode `0777` | `Apicrimes::create` | **Implemented** |
| BR-027 | If the crime insert id is empty, the method builds a 401 payload and then replaces it with HTTP 201 "crime created successfully" | `Apicrimes::create` | **Implemented** |

## Map, incidents, and geography

| ID | Rule | Evidence | Provenance |
|---|---|---|---|
| BR-028 | With no dates, the map uses the first day and the last day of the current month | `CrimeMapModel` | **Implemented** |
| BR-029 | `attempted` 0 or 1 filters `incident_type.attempted` to that value. Value 2 includes both | `CrimeMapModel`, `IncidentTypeModel` | **Implemented** |
| BR-030 | Map incident id `-1` is the attempted toggle and `0` is the all toggle. Both are removed before the SQL `IN` list. Either one disables the individual checkboxes in the generated HTML | `CrimeMap::getIncidentTypes` | **Implemented** |
| BR-031 | Points that share a crime id are merged. `name_group` values are concatenated when distinct | `CrimeMap::getCrimeMapIncidents` | **Implemented** |
| BR-032 | Incident-type delete sets `active` to 0. The list query does not exclude `active` 0 | `IncidentTypeModel` | **Implemented** |
| BR-033 | Incident-type create writes `name`, `note`, and `active`. It does not write `attempted`, `name_group`, or `color` | `IncidentTypeModel::createIncidentType` | **Implemented**. The map depends on those columns. **Requires confirmation** how they are populated |
| BR-034 | Organisation suburbs are rows in `org_location_map`, not the `organisations.suburb` column. Assignments to that column are commented out | `OrganisationsModel` | **Implemented** |
| BR-035 | GIS town list groups `organisations.town`. The suburb-based query is commented out because not every organisation has `org_location_map` rows | `ApiGisModel::getOrganisationSuburbs` | **Implemented**, and the comment states the reason |
| BR-036 | Town crime pages return 100 joined incident rows. `total_pages` is `CEIL(COUNT(crime_log.id) / 100)` without that incident join | `ApiGisModel` | **Implemented**. The two counts are not the same query |
| BR-037 | `getUserLocation` returns the organisation town and suburb list for the user with the posted email, after a valid GIS JWT | `Apigis::getUserLocation` | **Implemented** |

## Passwords and profile

| ID | Rule | Evidence | Provenance |
|---|---|---|---|
| BR-038 | Admin and API passwords are stored with `password_hash` / `PASSWORD_DEFAULT` | `UserModel::createUser`, seed | **Implemented** |
| BR-039 | Profile script: cellphone length 9 to 12. Password length must be at least 8 and must fail when length is 15 or more. It also requires one capital, one digit, and the character test in that script. The on-screen text says "Between 8 - 15 characters" | `settings/profile.php` | **Implemented**. The message and the `>= 15` branch disagree |
| BR-040 | A commented regex in the same script describes 6 to 18 alphanumeric characters. It is not executed | `settings/profile.php` | **Implemented** as a comment only |
| BR-041 | `Users::createUser` does not apply BR-039 | `Users.php` | **Implemented** |
| BR-042 | The mobile bundle states that password length should be more than 7 and that the two password fields must match | `main.f6fb2877.js` | **Implemented** string. Server enforcement of that length was not found |
| BR-043 | The users seeder hashes the literal password `123456`. The organisation seeder sets `api_key` to `123456` | `Seeds/Users.php`, `Seeds/Organisations.php` | **Implemented** seed. Whether those rows exist in production is **Unknown** |

## Other

| ID | Rule | Evidence | Provenance |
|---|---|---|---|
| BR-044 | Location list returns at most 10,000 rows, ordered by `mp_name` | `LocationsModel::getLocations` | **Implemented** |
| BR-045 | Organisation delete is a hard delete | `OrganisationsModel::deleteOrganisation` | **Implemented** |
| BR-046 | CORS allows any origin. CSRF protection is not enabled | `Cors.php`, `Filters.php` | **Implemented** |
| BR-047 | Export of the crime log, including notes, is a browser DataTables button set, not a server spreadsheet | `crimeLogListExport.php` | **Implemented** |

## Requires confirmation

- The display name and intended powers of role id 4, and of any role other than the seeded Developer.
- Whether verified crimes must be undeletable for every role, including by a direct POST.
- Whether public registration should create a Developer.
- Whether `organisations.api_key` or the GIS user-password check is the approved vendor credential.
- Whether `users.active` should block login.
- Which password policy is approved: profile script, mobile string, or none on the server.
