# PRD — crimemapping

Evidence date: 2026-10-18. Source: `git@bitbucket.org:overdrive/crimemapping.git`, `master` at `5927ab4` (2023-12-06).

There is no prior PRD in this repository or in `silicon_ark/crimemapping/`. Requirements below were recovered from this client. The admin API and the committed production bundle are documented in `silicon_ark/crimemapping_website/`. Where those documents disagree with this source, the disagreement is stated. Provenance is marked on each item.

## Product purpose

A signed-in person uses a phone-width web app to report a crime: when it happened, what type it was, notes, where it was, and optional photos. The report is posted to an HTTP API that this repository does not contain.

The README calls it the "Crime Mapping Web Application". The manifest calls it "Crime Mapping Mobile Application". Provenance: **Documented** (README, manifest). The reporting behaviour is **Implemented**.

This client does not draw a crime heatmap, filter historical crimes, or administer organisations. Those behaviours are in the admin application, not here.

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Signed-out visitor | Opens `/login`, `/signup`, or `/forgot-password`. Any other path is sent to `/unauth`, then to `/login` | `PublicRoutes.js`, `UnAuth.js` | **Implemented** |
| Signed-in user | Sees home and can open `/report-crime`. A menu offers log out. The screen shows their name and surname | `App.js`, `Home.js`, `NavigationBar.js` | **Implemented** |
| Field Operator | Named in `appConfig.js` as value `1` | `src/lib/appConfig.js` | **Implemented** constant. Not read by any routed screen |
| Data Manager | Named in `appConfig.js` as value `2`. A commented home button showed "Review Crimes" when `role_id === "2"` | `appConfig.js`, `Home.js` | **Implemented** as commented UI. Not on a live route |
| Organisation | Signup lists organisations from `GET /apiorganisations` and posts the selected id | `Signup.js`, `AuthContext.js` | **Implemented** |
| Reviewer using Firestore | Unrouted `AllEvents.js` allows the screen only when a Firestore `accounts` document has `userRole === 2` | `AllEvents.js` | **Implemented** in an unrouted file. The Firestore provider is not mounted |

`userDetails` loaded from `GET /apiusers/{id}` includes `role_id`, `organisation_id`, `name`, `surname`, `email`, and `active`. Home does not branch on `role_id` or `active`. Provenance: **Implemented**.

`crimemapping_website` documents a seeded role id 1 named Developer, and role id 4 as the organisation-scoped admin role. Those names are not in this repository. Whether `appConfig.js` or the admin seed is the product rule is **Unknown / Requires Confirmation**.

## Main workflows

### W1 — Sign up

Trigger: visitor opens `/signup` and submits the form.

Inputs:

- First name, last name, email (HTML `required`).
- Phone from `react-rainbow-components` `PhoneInput`, initial value `{ isoCode: "za" }`.
- Organisation, a `<select>` filled from `GET {REACT_APP_PUBLIC_API_URL}/apiorganisations`. Each option uses `org.id` and `org.name`.
- Password and confirm password.

Client rules before the request:

- Confirm password must equal password. Otherwise the alert is "Passwords do not match" and no request is sent.
- `Validation` rejects a password whose length is less than 7. The alert text is "Password length should be more than 7". A length of 7 passes the comparison and fails the sentence. The submit button stays disabled while `isNotValid` is true. It starts true.
- If `phone.countryCode` is `"+27"` and `phone.phone` length is less than 10, the client prefixes `"0"` before posting. Otherwise it posts `phone.phone` unchanged.

Request: `POST /apiusers/create` with JSON `organisation_id`, `name`, `surname`, `email`, `cellphone`, `password`. No `Authorization` header.

Response handling in `signupUserCallback`:

- `res.data.status === 403`: show `res.data.messages.success` as a danger alert, hide the form, and after 3 seconds clear the error and navigate to `/signup`.
- `res.data.status === 201`: hide the form, show `res.data.messages.success`, and after 3 seconds navigate to `/`.
- Any other status: no alert. The `console.log` of `res.data.messages.success` runs first and will throw if `messages` is missing.

`/ ` while signed out is redirected to `/unauth` and then `/login`. Signup does not set `currentUser` or store a token.

If `getOrganisationData` never returns, `organisation` stays `undefined` and the page renders `null`.

Provenance: **Implemented**. Server uniqueness, password hashing, and the role stored for a new user are not in this repository. The sibling API notes say user create is behind JWT, and this call sends no token. **Requires confirmation** whether signup succeeds against the current API.

### W2 — Sign in

Trigger: visitor submits `/login`.

Inputs (`LoginForm.js`): cellphone, `type="tel"`, `maxLength={10}`, required; password, required. No format check beyond the browser `required` and the maxlength.

Behaviour:

1. `setUser(cellphone, password)` stores the pair and an effect calls `POST /apiusers/login` with JSON `{ cellphone, password }`.
2. The login page then calls `navigate("/")` without waiting for the response.
3. `loginCallback`: if `res.data.id !== ""`, set `currentUser` to that id, keep `res.data.token`, and `localStorage.setItem("loginToken", token)`. If `id` is `""`, the function does nothing. There is no user-visible message.
4. Axios failures are logged. They do not set the "Failed to log into an account" alert. That alert is only for a thrown error around `setUser`, which does not throw on HTTP failure.

While `currentUser` is unset, path `/` is a public route and `PublicRoutes` sends it to `/unauth`, which waits 1.5 seconds and navigates to `/login`. The heading on that card is the string "Unautherised".

Provenance: **Implemented**. The sibling API returns `id` and `token` after `password_verify` and does not check `users.active`. That check is not in this client either.

### W3 — Restore a session

Trigger: app load.

`AuthProvider` reads `localStorage.loginToken`. If it is `null`, it returns. Otherwise `POST /apiusers/getUser` with `Authorization: Bearer {token}` and an empty body.

`getUserCallback` sets `currentUser` to `res.data[0].id` and restores the token only when `res.statusText === "OK"`.

`setLoading(false)` runs immediately after starting that request, not inside the callback. Children render on the first pass with `currentUser` still unset. Provenance: **Implemented**. A signed-in user can be shown the public routes until the callback lands. **Inferred** from the effect order.

Logout: menu "Log Out" calls `POST /apiusers/logout` with the bearer token, then removes `loginToken`, clears `currentUser`, and `window.location.reload(false)`. Failure is logged and the token is left in place because removal is in the callback.

### W4 — Home

Trigger: `currentUser` is set and the path is `/`.

`PrivateRoute` shows a spinner while `useAuthStatus` is checking, then renders the outlet when `loggedIn` is true. `useAuthStatus` sets `checkingStatus` false as soon as the effect runs, so the spinner is brief.

Home loads `GET /apiusers/{currentUser}` with the bearer token and stores `user_id`, `role_id`, `organisation_id`, `name`, `surname`, `email`, `active`. Until both `userDetails` and `userCurrentPosition` are non-null, Home renders `null`.

The only action is a link "Report a Crime" to `/report-crime`. The footer shows `v` plus `package.json` version (`0.3.2`) and "Logged is as: {name} {surname}".

A "Review Crimes" link for `role_id === "2"` is commented out (commit subject "remove review crimes.", `2119323`).

On entering a private route whose path is not `/login`, `/signup`, or `/forgot-password`, the shell calls `navigator.geolocation.getCurrentPosition` with `enableHighAccuracy: true`, `timeout: 50000`, `maximumAge: 100`. Success stores accuracy, altitude, latitude, and longitude with `trueposition: true`. Failure stores `trueposition: false`, accuracy `0`, latitude `-29.555`, longitude `25.107`, and the alert "Cannot get current position".

Provenance: **Implemented**.

### W5 — Report a crime

Trigger: signed-in user opens `/report-crime`. The page renders `null` until `GET /apiincidents` returns. The request sends the bearer token. Each row becomes `{ id, value: name_group, label: name }`. Options are sorted by `value`.

The form:

- Date and time, `DateTimePicker`, locale `en-US`, default `new Date()`.
- Multi-select "CRIME INCIDENT". The "Create Crime Event" button stays disabled until at least one option is selected.
- Notes textarea. Not required.
- Map. When geolocation exists, zoom is 15. When `trueposition` is false, zoom is 4. Dragging the map updates the marker to the map centre. On first position, `mapDrag` is set to that latitude and longitude.
- "Upload Photo" accepts `multiple` files, shows a preview, and can remove one. No client check of type or size.
- Submit opens a modal titled "Log Crime Event". Body text: "Are you sue you want to report this crime?" Create posts the form. Cancel shows "Event creation Cancelled" and after 3 seconds navigates to `/`.

`POST /apicrimes/create` as `multipart/form-data` with bearer token:

| Field | Source |
|---|---|
| `files[]` | each selected `File`, when the files array is truthy |
| `user_id` | `userDetails.user_id` |
| `organisation_id` | `userDetails.organisation_id` |
| `report_date` | picker value `toLocaleString('en-US', { hour12: false })` |
| `coordinates_long` | `mapDrag.lng` |
| `coordinates_lat` | `mapDrag.lat` |
| `note` | notes state |
| `incidents` | JSON object whose keys are `"0"`, `"1"`, … and whose values are the selected incident ids |
| `coordinates_address` | reverse-geocode result |

Reverse geocode uses `react-geocode` with `REACT_APP_GOOGLE_API_KEY` and location type `ROOFTOP`. The formatted address of `results[0]` is stored. On failure the client sets location type to `APPROXIMATE` and does not send a second request. Address can still be unset when the user submits.

`addCrimeEntryCallback`: if `res.data.error !== null`, set the alert "Something went wrong" and `navigate("/")`. It then always sets success and hides the form. A missing `error` property is not `null`, so that branch runs for a body that has no `error` field. Success text is "Event created", then `/` after 3 seconds.

If `userDetails` is null, `userDetails.user_id` throws. Home withholds itself until details load. `/report-crime` does not.

Provenance: **Implemented**.

The sibling API document says `Apicrimes::create` requires a bearer JWT, inserts the crime unverified, splits `report_date` on a comma, reads incident ids from JSON field `incidents`, and accepts jpeg, jpg, or png up to 12097152 bytes, resampling width above 400 pixels. This client does not enforce those file rules. The client field name is `files[]`. The API note says `files`. In PHP that name is the array form of `files`. Whether the live endpoint accepts this client's date string and file field is **Requires confirmation** against a current request, not against this repository alone.

### W6 — Forgot password

Trigger: `/forgot-password` submits an email.

The screen calls `resetPassword` from `useAuth()`. In `AuthContext.js` that function is commented out (it used to call Firebase `sendPasswordResetEmail`) and is not on the context value. The call throws, and the alert is "Failed to reset password".

Provenance: **Implemented** as a screen that cannot succeed. The sibling API methods `Apiusers::ForgotPassword` and `ChangePassword` are empty. This screen does not call them.

### W7 — Review, view, and debug (not routed)

`PrivateRoutes.js` comments out `/debug`, `/all-crimes`, and `/view-crime`. The modules remain.

`AllEvents.js` (title "Review Crimes"):

- Reads Firestore collection `events` and account `accounts/{uid}`.
- Renders only when `userRole === 2`.
- Map markers call `getSelectedMarker`. Two or more selected events enable "Complete Merge".
- Merge writes a new `events` document with a new uuid, `review: true`, `merged: true`, `edited: false`, location averaged across the selected points, and `mergedEntries`. Selected events are copied to collection `archive` and deleted from `events`.
- The file calls `useFirestore()` but the import is commented out, and `FirestoreProvider` is not mounted in `index.js`. Opening the route would throw.

`ViewEvent.js` reads Firestore `events/{id}` from query `?id=` and shows `_id`, `_timestamp`, `selectedTime`, `selectedCrime`, `descriptionText`, one image from Storage path `ReactEventImages/{id}/{beforePhotoUploadName}`, and a map at `Entry.location`. Name, surname, and mobile blocks are commented out. `useFirestore` is used and not imported in the portion read.

`Debug.js` prints device and position details and was written to edit Firestore `accounts.userRole` and `organisation` from `appConfig`. The loader and `updateEntry` it calls are commented out or undefined. Route commented out.

`EventConfig.js` lists 25 crime labels (aggravated robbery subtypes, arson, assault, attempted murder, common assault, common robbery, damage to property, murder, burglary, sexual offences, theft subtypes). The live report form does not import it. The unrouted accordion `components/Accordion/Default.js` does. Labels include the spelling "Agrravated".

Provenance: **Implemented** files, **not** reachable from the router at `5927ab4`. Commit `2119323` removed the review entry from the home screen. Whether the product still wants review and merge is **Unknown / Requires Confirmation**.

## Functional requirements

FR-001 — Unsigned routes

The signed-out app exposes `/signup`, `/login`, `/forgot-password`, and `/unauth`. Paths `/`, `/report-crime`, `/all-crimes`, and `/view-crime` navigate to `/unauth`. Any other path hits `*` and `<Navigate to="/" />`, which the effect then sends to `/unauth`.

Evidence: `PublicRoutes.js`. Implementation: **Implemented**.

FR-002 — Signed routes

The signed-in app exposes `/` (home) and `/report-crime`. Paths `/login`, `/signup`, and `/forgot-password` navigate to `/`.

Evidence: `PrivateRoutes.js`. Implementation: **Implemented**.

FR-003 — Session token

A successful login stores `loginToken` in `localStorage` and sends it as `Authorization: Bearer` on logout, getUser, user fetch, incident list, and crime create. Signup and organisation list do not send it.

Evidence: `AuthContext.js`, `DatastoreContext.js`. Implementation: **Implemented**.

FR-004 — Crime report minimum

The create button is disabled until `crimeSelect.length > 0`. No other field disables it.

Evidence: `CreateEvent.js`. Implementation: **Implemented**.

FR-005 — Position fallback

If geolocation fails or is unavailable, the map centre is latitude `-29.555`, longitude `25.107`, and zoom is 4.

Evidence: `PrivateRoutes.js`, `MapMain.js`. Implementation: **Implemented**.

FR-006 — Password length

Signup rejects length less than 7 and shows "Password length should be more than 7".

Evidence: `src/lib/validation.js`, `Signup.js`. Implementation: **Implemented**. The message and the comparison disagree.

## Integrations

| System | Direction | Trigger | Data | Auth | Failure in this client |
|---|---|---|---|---|---|
| HTTP API at `REACT_APP_PUBLIC_API_URL` | Client to server | Login, signup, logout, session, user, incidents, crime create | See W1–W5 | Bearer token except login, signup, and organisation list | Logged to the console. Login and most API errors have no dedicated alert. Crime create shows "Something went wrong" when `res.data.error !== null` |
| Google Maps JavaScript API | Client to Google | Private shell loads `@react-google-maps/api` | Map tiles for the report map | `REACT_APP_GOOGLE_API_KEY` | `loadError` is captured in `PrivateRoutes` and not rendered on the live routes |
| Google Geocoding | Client to Google | Map centre changes | Lat/lng to `formatted_address` | Same key, via `react-geocode` | Sets location type `APPROXIMATE` and leaves the previous address |
| Browser geolocation | Device to page | Private route entered | Coordinates | Browser permission | Fallback coordinates and alert "Cannot get current position" |
| Firebase Auth, Firestore, Storage | Not called from the live tree | `firebase-config.js` initialises with empty strings. `FirestoreProvider` is never mounted | — | — | Live routes do not call it |
| FTP | Pipeline to host | Bitbucket default pipeline | Contents of `build/` | `FTP_USERNAME`, `FTP_PASSWORD` | Not handled in the YAML beyond the `ncftpput` command |
| SonarQube | Defined step, not in `pipelines.default` | Manual only if someone runs that step | Scan of `.` | `SONAR_TOKEN` | The default pipeline does not run it |

The base URL is not committed. Sibling documentation records `https://admin.crimemapping.co.za/public` in the app build's `.env` and in the bundle. Provenance of that host: **Documented** in `crimemapping_website`, not in this repository.

## Constraints

- Layout width is capped at 400px with background `#0e0f14`. Provenance: **Implemented** inline style.
- Production build sets `PUBLIC_URL=https://app.crimemapping.co.za/` and `CI=false`. Provenance: **Implemented** in `package.json` `build`.
- `.env` is gitignored. The client cannot call the API or Maps until those variables exist at build time.
- `noscript` in `index.html`: "You need to enable JavaScript to run this app."
- `robots.txt` allows all user agents (`Disallow` empty).

## Non-goals visible in this repository

No code here implements heatmaps, admin verification, GIS town feeds, exports, or password reset. Those belong to `crimemapping_website` or are stubbed.

## Human confirmation

- Production API base URL and whether signup without a bearer token is accepted.
- Whether role ids in `appConfig.js` are obsolete.
- Whether review, merge, and Firestore should be deleted or restored.
- Whether the forgot-password screen should call an API that does not exist yet.
- Whether the success path that also treats a missing `error` field as failure is intended.
- Whether `users.active` must block login. This client does not read it for access.
