# PRD — formulys-mobile-app

Evidence date: 2026-10-14.

Source: `git@bitbucket.org:overdrive/formulys-mobile-app.git`, `main` at `8fef7bc` (2024-01-30).

There is no prior PRD in the source repository. Silicon Ark had no `formulys-mobile-app/` directory before this file. `README.md` is the Expo Router template and does not state requirements. Requirements below are recovered from the Expo Router screens, `app.json`, `package.json`, and the Android project. Provenance is labelled. The app was not run on a device or emulator during this inspection.

Naming in git is inconsistent. The UI says **Pest Elite**. The npm package is `formulys-mobile`. The Expo slug is `pestelite-mobile`. See the project plan.

## Product purpose

**Documented:** the home screen prints "Pest Elite" and "Pest Control & Hygiene Service" (`app/index.js`). Nothing else in git states a product purpose.

**Implemented.** After a JWT is stored, the person can open a home screen, a drawer, and three registers (site inspections, clients, pesticides). Lists are native cards loaded from Form.io. Creating, viewing, and editing a record opens a FormView URL in a WebView.

**Inferred.** The app is a mobile client for Pest Elite field staff to look up sites, clients, and pesticides and to fill the Form.io forms for those registers.

**Unknown / Requires Confirmation:** product owner, customer, distribution channel, and whether this client is supposed to share users with `pestelite_html`.

## Users and roles

| Actor | Evidence | Status |
|---|---|---|
| Person without a stored token | `app/_layout.js` sends them to `/login` when the route is not login or sign-up | **Implemented** |
| Person with a stored token | Same layout sets `loggedIn` and leaves them on the route | **Implemented**. The token is not checked with Form.io on launch |
| Person who can register | `/sign-up` posts email and password to Form.io `user/register` | **Implemented**. Who is allowed to self-register is decided by Form.io, which is **Unknown** |
| Named roles (admin, technician, client) | No role string is read anywhere in this repository | **Not implemented** here. `pestelite_html` documents roles on `pesteliteusers`. This app does not call that resource |

There is no permission check on the drawer, the lists, or the WebViews. Any stored token is enough to open every screen.

## Systems the product talks to

| System | Direction | Trigger | Data |
|---|---|---|---|
| Form.io `https://rzkiqpzlxxtfqjl.form.io` | App → Form.io | Login, register, and the three list screens | JSON. Auth header `x-jwt-token` on the list GETs. Login and register send that header as an empty string |
| FormView `https://pro.formview.io` | WebView → FormView | Create, detail, and edit | The URL only. This repo does not add the JWT, cookies, or headers to the WebView |
| EAS | Declared | Not triggered by app code | Project id `d2ffa4b5-2390-43bc-93f6-289744487883` in `app.json` |
| AsyncStorage | Device local | Login, register, logout, list fetch, route gate | Key `token` |

There is no database client, queue, push provider, or analytics SDK in the routed code.

The Form.io host is the same host documented for `pestelite_html` and `formulys_simpleflow`. Login here is `user/login`. `pestelite_html` uses `pestelitelogin`. SimFlow uses `simflow*` paths. Whether those products share accounts is **Unknown / Requires Confirmation**.

## Routes

File routes under `app/`. The root layout wraps all of them.

| Path | Screen | Who reaches it |
|---|---|---|
| `/` | Home in `app/index.js` | Anyone who already has a token. Without a token the layout replaces the route with `/login` |
| `/login` | Email and password login | Linked from sign-up. Also the redirect target. The token check is skipped on this segment, so a stored token does not skip the form |
| `/sign-up` | Email and password register | Linked from login. Token check is skipped |
| `/dashboard` | Drawer layout only. There is no `app/dashboard/index.js` | Login replaces the route with `/dashboard` after success. Which child screen Expo Router shows is **Unknown** — it was not run |
| `/dashboard/sites` | Site inspection cards | Home button "Site Forms", and the drawer label "Site Inspections" |
| `/dashboard/sites/[id]` | Site detail WebView | Card press. Passes `siteId` |
| `/dashboard/sites/[id]/edit` | Site edit WebView | Header link "Edit" |
| `/dashboard/clients` | Client cards | Home button "Clients", and the drawer |
| `/dashboard/clients/[id]` | Client detail WebView | Card press. Passes `clientId` |
| `/dashboard/clients/[id]/edit` | Client edit WebView | Header link "Edit" |
| `/dashboard/pesticides` | Pesticide cards | Drawer label "Pesticides". The home screen has no pesticide button |
| `/dashboard/pesticides/[id]` | Pesticide detail WebView | Card press. Passes `pesticideId` |
| `/dashboard/pesticides/[id]/edit` | Pesticide edit WebView | Header link "Edit" |
| `/forms/[id]` | Create-form WebView | The `+` button on each list. The query param `formId` selects the URL. The path segment is also named `formId` |

`components/` contains a second home, a second login, and form WebViews. Nothing under `app/` imports them. They are not routes. See features.

## Major workflows

### WF-1 — Open the app

**Implemented** in `app/_layout.js`.

Trigger: the segment list changes, and the segments do not include `login` or `sign-up`.

1. Read AsyncStorage key `token`.
2. If it is missing, set `loggedIn` false and `router.replace("/login")`.
3. If it is present, set `loggedIn` true. The layout does not call Form.io to validate or refresh the token.
4. On error, `console.error` and leave the route as it is.

The effect comment says it runs once. The dependency array is `[segments]`, so it runs again when navigation changes. Provenance: **Implemented**.

A stored token does not move the person off `/login` or `/sign-up`, because those segments skip the check.

### WF-2 — Log in

**Implemented** in `app/login/index.js`.

Trigger: press Login.

Inputs: email and password strings. The screen does not check format or emptiness before the request.

1. POST `{ data: { email, password } }` to `https://rzkiqpzlxxtfqjl.form.io/user/login/submission` with `Content-Type: application/json` and `x-jwt-token` empty.
2. On success, store `response.headers["x-jwt-token"]` in AsyncStorage `token` and `router.replace("/dashboard")`.
3. On failure, set the visible error to "Could not login, please try again" and `console.error` the error.
4. While the request is in flight, the button is replaced by `Spinner`.

**Unknown / Requires Confirmation:** what Form.io returns for a wrong password, a disabled user, or a missing `x-jwt-token` header, and whether `user/login` is the Pest Elite user directory or a different Form.io user resource.

`stores/userStore.js` also posts to `user/login` (without `/submission`) through `lib/helpers.js`. No screen imports that store. The helper references `axios` without importing it, and it does not await `AsyncStorage.getItem`. That path is not the login the UI uses.

### WF-3 — Register

**Implemented** in `app/sign-up/index.js`.

Trigger: press Register.

Inputs: email and password, unchecked on the client.

1. POST `{ data: { email, password } }` to `https://rzkiqpzlxxtfqjl.form.io/user/register/submission` with an empty `x-jwt-token`.
2. The call chains `.catch`, which logs via `logger` and swallows the rejection. A failed HTTP response therefore becomes a resolved `undefined`.
3. The next line reads `response.headers["x-jwt-token"]`. If `response` is undefined, that throws, the outer catch runs, and the screen shows "Could not register, please try again".
4. On a response object, the token is stored and `router.push("/dashboard")` (push, not replace).

**Unknown / Requires Confirmation:** whether Form.io allows open registration on `user/register`, and whether a successful body always includes `x-jwt-token`.

### WF-4 — Log out

**Implemented** in `app/_layout.js`.

Trigger: press the header control. The label is the string "Logout" only when `loggedIn` is true. The press handler is always attached.

1. Remove AsyncStorage `token`.
2. `router.replace("/login")`.
3. There is no Form.io logout request.

After navigation, segments include `login`, so the token effect returns early and does not set `loggedIn` to false. The header can still show "Logout" on the login screen until some later protected route runs the check. Provenance: **Implemented**. Not runtime-tested.

### WF-5 — Home

**Implemented** in `app/index.js`.

Shows the company name and slogan. "Site Forms" replaces the route with `/dashboard/sites`. "Clients" replaces the route with `/dashboard/clients`. Pesticides are not linked here.

### WF-6 — List a register

**Implemented** separately in:

- `app/dashboard/sites/index.js` — GET `.../digimonkv1/submission`
- `app/dashboard/clients/index.js` — GET `.../clientregister/submission`
- `app/dashboard/pesticides/index.js` — GET `.../pesticidesregister/submission`

Trigger: the screen mounts, and again when `segments` change.

1. Set loading and show `Spinner`.
2. Read `token`. If it is missing, clear loading and return. The list stays empty. There is no redirect from the list itself (the root layout is what redirects).
3. GET the collection with `x-jwt-token` set to the stored token. No `limit`, `skip`, `select`, or `sort` query is set.
4. On success, replace the list with `response.data`. Sites and pesticides also `console.log` the JSON.
5. On failure, `console.error` only. Loading stops. No error text is shown, so the person sees an empty list.
6. Cards alternate `PEST_ELITE_COLORS` (`#354785`, `#bbbbbb`) with white text.

**Unknown / Requires Confirmation:** whether Form.io returns every submission or a default page. This client does not ask for a page.

Empty data renders an empty `FlatList`. There is no empty-state message.

### WF-7 — Create a record

**Implemented** by the `+` link on each list and `app/forms/[id].js`.

| List | `formId` param | FormView URL |
|---|---|---|
| Sites | `digimonk` | `https://pro.formview.io/#/rzkiqpzlxxtfqjl/digimonkv1?header=0&reset=1` |
| Clients | `register-client` | `.../clientregister?header=0&reset=1` |
| Pesticides | `pesticidesregister` | `.../pesticidesregister?header=0&reset=1` |

An unknown `formId` sets the WebView URI to `undefined`. There is no fallback screen.

The WebView does not receive the JWT. Whether FormView shows the form, a Form.io login, or an error is **Unknown / Requires Confirmation**. Nothing in this repo listens for a submit event or refreshes the list when the WebView closes.

### WF-8 — View and edit a record

**Implemented** in the `[id]` and `[id]/edit` screens.

| Register | Detail URL | Edit URL |
|---|---|---|
| Site | `.../digimonkv1/submission/{siteId}?header=0&reset=1` | `.../submission/{editId}/edit?header=0&reset=1` |
| Client | `.../clientregister/submission/{clientId}?...` | same shape with `editId` |
| Pesticide | `.../pesticidesregister/submission/{pesticideId}?...` | same shape with `editId` |

Detail screens put "Edit" in the header. Edit screens title the header "Edit". Site and pesticide detail also set `injectedJavaScript` that looks for `app-root` and removes added nodes that do not have class `main`. The call is `setTimeout(mutationObserver.observe(appRoot, config), 2000)`, which invokes `observe` immediately and passes its return value to `setTimeout`. A two-second delay is written and does not wrap the call. Client detail has no injected script. Edit and create WebViews have no injected script.

Site and pesticide detail include a JSX attribute `j` with no value (`j={true}`). Nothing reads it.

No screen sends DELETE. No screen has a delete control.

## Functional requirements

### FR-001 — Token gate

**Requirement:** For any route whose segments do not include `login` or `sign-up`, the app reads AsyncStorage `token` and replaces the route with `/login` when the value is missing.

**Who:** anyone opening the app.

**Trigger:** segment change.

**Inputs:** AsyncStorage `token`.

**Expected behaviour:** missing token navigates to login and clears the logged-in flag. A present token sets the flag and does not navigate. A read error is logged and does not navigate.

**Validation:** none on the token contents.

**Error:** `console.error("Error checking token:", error)`.

**Evidence:** `app/_layout.js`. **Implementation:** Implemented. **Confidence:** High for the source. Not runtime-tested.

### FR-002 — Login

**Requirement:** The login screen collects email and password and exchanges them for a JWT.

**Who:** a person on `/login`.

**Trigger:** Login press.

**Inputs:** email, password.

**Expected behaviour:** POST to `user/login/submission` as in WF-2. Success stores the `x-jwt-token` response header and replaces the route with `/dashboard`. Failure shows "Could not login, please try again".

**Validation:** none in the client. Secure text entry is on for the password field.

**Error:** one generic string for every failure, including network failure.

**Evidence:** `app/login/index.js`. **Implementation:** Implemented. **Confidence:** High.

**Discrepancy:** `pestelite_html` posts login to `pestelitelogin/submission`, not `user/login/submission`.

### FR-003 — Register

**Requirement:** The sign-up screen collects email and password and posts them to Form.io user registration.

**Who:** a person on `/sign-up`.

**Trigger:** Register press.

**Inputs:** email, password.

**Expected behaviour:** as WF-3. Success stores the JWT header and pushes `/dashboard`. The visible failure string is "Could not register, please try again".

**Validation:** none in the client.

**Error:** HTTP failures are swallowed by `.catch` and then surface as the generic string when `response.headers` is read. A successful response that has no `x-jwt-token` header stores that missing value. AsyncStorage's behaviour for a missing header was not tested.

**Evidence:** `app/sign-up/index.js`. **Implementation:** Implemented. **Confidence:** High for the control flow. Form.io's registration policy is Unknown.

### FR-004 — Logout

**Requirement:** The header control removes the stored token and opens `/login`.

**Who:** the person using the root stack header.

**Trigger:** press.

**Expected behaviour:** `AsyncStorage.removeItem("token")`, then `router.replace("/login")`. No server call.

**Evidence:** `app/_layout.js`. **Implementation:** Implemented. **Confidence:** High.

### FR-005 — Home navigation

**Requirement:** The home screen shows the Pest Elite name and slogan and navigates to site forms and clients.

**Who:** a person who passed the token gate.

**Trigger:** button press.

**Expected behaviour:** "Site Forms" → `/dashboard/sites`. "Clients" → `/dashboard/clients`.

**Evidence:** `app/index.js`. **Implementation:** Implemented. **Confidence:** High.

### FR-006 — Site inspection list

**Requirement:** The site screen lists `digimonkv1` submissions as cards.

**Who:** a person with a token who opens Site Forms.

**Trigger:** mount and segment change.

**Inputs:** JWT.

**Expected behaviour:** each card shows `data.clientName`, `data.selectClient.data.accountNumber`, `data.contractNumber`, `data.techName`, and `data.nextVisit` (date portion before `T`, or the literal `TBD` when `nextVisit` is missing). Press opens `/dashboard/sites/{_id}`.

**Validation:** `nextVisit` is optional-chained. `selectClient.data.accountNumber` is not. A submission without `selectClient` throws while rendering.

**Error:** request failure leaves an empty list and a console error. No retry control.

**Evidence:** `app/dashboard/sites/index.js`. **Implementation:** Implemented. **Confidence:** High.

Stack title for this section is "All Sites" (`app/dashboard/sites/_layout.js`). Drawer label is "Site Inspections".

### FR-007 — Site detail and edit

**Requirement:** Site detail loads the FormView submission page and links to the FormView edit page.

**Who:** a person who opened a site card.

**Trigger:** navigation. Edit is a header link.

**Inputs:** `siteId` on detail, `editId` on edit.

**Expected behaviour:** WebView URLs in WF-8. Injected script as written on the detail screen only.

**Evidence:** `app/dashboard/sites/[id]/index.js`, `app/dashboard/sites/[id]/edit/index.js`. **Implementation:** Implemented. **Confidence:** High for the URLs. What FormView renders without a JWT is Unknown.

### FR-008 — Client list

**Requirement:** The client screen lists `clientregister` submissions as cards.

**Expected behaviour:** each card shows `data.clientName`, `data.accountNumber`, `data.contractNumber`, `data.contractExpiry` (date portion before `T`), `data.siteContact`, `data.telephone`, and `data.email`. Press opens `/dashboard/clients/{_id}`.

**Validation:** `contractExpiry.split` is called with no guard. A missing `contractExpiry` throws while rendering.

**Error:** same as FR-006 (empty list, console error).

**Evidence:** `app/dashboard/clients/index.js`. **Implementation:** Implemented. **Confidence:** High.

Stack title is "All Clients".

### FR-009 — Client detail and edit

**Requirement:** Client detail loads the FormView submission page and links to edit.

**Expected behaviour:** URLs in WF-8. No injected script.

**Evidence:** `app/dashboard/clients/[id]/index.js`, `app/dashboard/clients/[id]/edit/index.js`. **Implementation:** Implemented. **Confidence:** High for the URLs.

### FR-010 — Pesticide list

**Requirement:** The pesticide screen lists `pesticidesregister` submissions as cards.

**Who:** a person who opens the drawer item. Home does not link here.

**Expected behaviour:** each card shows `data.pesticide` and `data.type`. Press opens `/dashboard/pesticides/{_id}`. The `+` control opens the create WebView.

**Validation:** neither field is guarded.

**Error:** same as FR-006.

**Evidence:** `app/dashboard/pesticides/index.js`. **Implementation:** Implemented. **Confidence:** High.

Stack title is "All Pesticides". Drawer label is "Pesticides".

`pestelite_html` also shows dosage on the pesticide card. This app does not read `dosageDilution`. Provenance of the difference: **Implemented** in each repo. Which card is current is **Requires Confirmation**.

### FR-011 — Pesticide detail and edit

**Requirement:** Pesticide detail loads the FormView submission page and links to edit.

**Expected behaviour:** URLs in WF-8, plus the same injected script and stray `j` attribute as site detail.

**Evidence:** `app/dashboard/pesticides/[id]/index.js`, `app/dashboard/pesticides/[id]/edit/index.js`. **Implementation:** Implemented. **Confidence:** High for the URLs.

### FR-012 — Create form map

**Requirement:** `/forms/[id]` opens one of the three FormView create URLs based on `formId`.

**Evidence:** `app/forms/[id].js`. **Implementation:** Implemented. **Confidence:** High.

The file imports `use` from `expo-router` and does not call it.

### FR-013 — Drawer

**Requirement:** The dashboard drawer exposes Site Inspections, Clients, and Pesticides, with matching titles.

**Evidence:** `app/dashboard/_layout.js`. **Implementation:** Implemented. **Confidence:** High for the screen options. Whether the drawer gesture is available on the home route was not tested. Home is outside `app/dashboard/`.

## Integrations

### Form.io user resource

- **Purpose:** obtain a JWT.
- **Trigger:** login or register press.
- **Direction:** app → Form.io.
- **Authentication:** empty `x-jwt-token` on the way out. The response header is the session.
- **Expected response:** a header `x-jwt-token` that is stored as-is. The response body is not read.
- **Failure:** generic alert string. No status-code branching.

### Form.io submissions

- **Purpose:** list sites, clients, and pesticides.
- **Trigger:** list screen focus via `segments`.
- **Direction:** app → Form.io.
- **Authentication:** `x-jwt-token` from AsyncStorage.
- **Expected response:** an array of submission objects with `_id` and `data`.
- **Failure:** console only, empty list, no retry.

### FormView

- **Purpose:** create, read, and edit the form that lives on Form.io.
- **Trigger:** `+`, card, or Edit.
- **Direction:** WebView loads a URL. No message bridge back to React Native.
- **Authentication:** none added by this app.
- **Failure:** not handled. The WebView has no `onError` prop.

## Constraints

- Expo SDK 49 and React Native 0.72, as pinned in `package.json`.
- Android package `com.siliconoverdrive.pestelite`. iOS id is still `com.yourcompany.yourappname`.
- Deep-link schemes declared on the Android activity: `acme`, `com.jasonoverdrive.formulysmobile`, and `com.siliconoverdrive.pestelite` (`AndroidManifest.xml`). `app.json` only sets scheme `acme`.
- Lists and cards assume the Form.io `data` shape used in January 2024. The form definitions are not in git.
- Release Android builds are configured to sign with the debug keystore (`android/app/build.gradle`).

## Out of scope in this repository

- Editing form schemas.
- User administration, roles, and password reset.
- Delete.
- Offline storage of submissions.
- Search, filter, sort, and paging controls.
- iOS project files.
- Tests and CI.

## Requires confirmation

- Product name and owner.
- Whether `user/login` and `pestelitelogin` are the same population.
- Whether FormView pages work without the JWT being injected.
- Whether list GETs return the full collection.
- Whether `contractExpiry` and `selectClient` are always present on live submissions.
- What `/dashboard` shows when login succeeds, given there is no `app/dashboard/index.js`.
- Whether self-registration is still enabled on the Form.io project.
