# Mobile API migration: legacy controllers → central services

Status: **All phases done (0–5).** Remaining work is app-side (see ❓ items) and running the tenant migrations. · Started 2026-09-28

## Why

The mobile (HCP app) API in `routes/tenant/mobile.php` is v2 code copied over
unchanged. **None of it runs in this codebase today**, and every endpoint 500s
before reaching its method body:

- Every route, including `login` / `register` / `forgot-password`, sits inside
  `auth:api`, so you must be logged in to log in.
- Constructors call `$this->middleware()`, which Laravel 12's empty base
  `Controller` doesn't have. They also type-hint interfaces that no longer
  exist, or that have moved namespace.
- FormRequests are imported from `App\Http\Requests\API\*`, but they now live
  in `App\Http\Requests\Mobile\*`.
- Every `api_*` repository method they call exists only in v2.
- `app/Http/Helpers.php` is not autoloaded, and there is no `lang/` directory,
  so every message renders as its key (`"messages.records_saved"`).

So there is no working behaviour to preserve here. **The contract the app
speaks is v2's:** `D:\DevOps\iShapps\backend`, including
`resources/lang/en/messages.php` for the literal strings. The rewrite must
match v2's request and response shapes, and implement the logic through
central services.

## Decisions (agreed)

| Topic | Decision |
|---|---|
| Dropped features (swap, buddy, refer-and-earn, HCP transfer flow, HCP self-cancel) | **Delete the routes and code.** |
| Login | **One central auth service.** SPA `/auth/login` keeps rejecting HCPs. Mobile `/login` stays at its path, is HCP-only, and keeps v2's response shape. |
| Tenancy on mobile | The app needs the **email-first flow**: `POST /tenancy/resolve` (central) → pick organisation → send `X-Tenant-Code` on every call. **This needs an app release**, because the v2 app never sent that header. |
| Everything else | Keep every route and v2's response shape. |
| HCP self-signup (`hcp/register`) | **Delete.** Workers are recruited in Hiresome. HR approval creates the HCP account, which sends the user welcome email; the worker then does self-onboarding in the app. |
| Error HTTP codes | **v2's convention.** Business failures are `200 {status:false}`, including ShiftService's 422/404, which the mobile controllers convert. |
| HCP-side profession adding (`add-update-profession`, `-new`) | **Delete** (deprecated; professions come from Hiresome). |
| Payments (`shift/payments`, `payment-details`) | **Delete.** The `transactions` table is gone. |
| Sensitive fields (TFN, bank, super) in login/profile | **Keep.** The app uses them. |

## Response envelope (from v2, must be kept)

- Success: `200 {"status": true, "message": <v2 string>, "data": [] | {…}}`
- Business failure: **`200`** `{"status": false, "message": …, "data": null}`
- Exception: `500 {"status": false, "message": "Oops! Something went wrong.", "data": null}`
- FormRequest failure: `422 {"message": "The given data is invalid", "errors": [<flat strings>]}`

Known quirks to reproduce:
- `data.hcps` is the key for **documents** and **notifications** lists.
- `hcp/register` returns `hco_id` (typo).
- `update-device-id` returns `data: null` on success.
- `change-password` success has no `data` key.

→ One `MobileResponse` helper (`ok()` / `fail()` / `error()`) builds all of
these, so the envelope lives in one place instead of 70 hand-built arrays.

## Phases

Each phase ends with syntax/type checks, plus contract tests that assert the
v2 JSON shape per endpoint.

### Phase 0 — Foundation ✅
- [x] `lang/en/messages.php` holds the generic envelope keys, verbatim from v2. Each phase adds its own.
- [x] `App\Http\Responses\MobileResponse` provides `ok` / `fail` / `error` / `fromHttpException`.
- [x] `App\Http\Requests\Mobile\MobileFormRequest` gives v2's flat `errors` shape on validation failure.
- [x] Routes:
  - 22 dropped routes removed; 45 remain.
  - login, forgot-password and the 8 read-only content endpoints are public; login and forgot-password are throttled.
  - The `/hcps` collision is gone.
- [x] **Undefined helpers in live central code.** `Helpers.php` was never loaded, so every call crashed.
  - `get_general_configuration` / `get_loading_time_configuration` / `get_string_configuration` → `Config\GeneralConfigService` (24 call sites across 10 files).
  - `get_facility_and_hcp_distance` → `LocationService::distanceKm` (geofence job). This also fixes a metres-vs-km bug: arrival only fired within about 1 m.
  - `send_shift_push_notification`, `send_admin_notification` and `remove_new_shift_notification` → `NotificationService` events. That meant:
    - a new `inbox` channel plus `NotificationInbox` (the in-app notification rows);
    - `PushChannel` now sends the app's deep-link `data` payload;
    - the resolver renders the legacy tags (`@ShiftDate`, `@ShiftStartTime`, …) itself;
    - 12 new routing keys.
  - Duplicate sends were removed; several call sites would have double-pushed.
- [x] **ShiftService:**
  - now `final`;
  - the undeclared `$shiftTransfer` / `$shiftTracking` are replaced by model helpers — `start()`, cancel, void and return-to-pending had always failed;
  - `transfer()` enum value fixed (`accept`);
  - `end()` works for client shifts;
  - `calculatePrice()` pointed at a non-existent `\App\Models\BreakTimeConfig`, so every create, update and approve failed.
- [x] **HCP matching (broadcast + admin picker):**
  - removed the `buddy_shift_requests` whereHas: the relation doesn't exist, so the query threw;
  - removed the `points > 0` gate (penalty points are dropped, and it excluded every HCP with NULL points);
  - removed the swap exclusions;
  - removed `date_default_timezone_set()`.
- [x] Mass assignment: `Hcp` (status, availability, bank/super, …) and `HcpProfession` (status, accept_level_ids).
- [x] Wrong model namespaces: `PayrollLine` relations, the client shifts tab, shift export, and 3 export classes (`ExportRun`).
- [x] Dropped plan billing and the transactions model:
  - removed the `Facility::plans()` / `facility_plan()` and `Profession::plans()` relations, and the 4 `Transaction` relations on `Shift`/`Hcp` (all unused);
  - deleted the `FacilityPlan` and `Transaction` models;
  - kept the `facility_plan` / `transactions` tables and migrations: dropping them from tenant databases is a separate data decision.
- [ ] Still to do: `create_hcp_activity` (only mobile controllers use it, so it goes away with them in Phase 3).

### Phase 1 — Auth (central login) ✅
- [x] `App\Services\Auth\AuthService` is the one login path, raising `LoginFailed` (`invalid_credentials`, `inactive`, `wrong_app`).
  - `loginForSpa()` rejects HCPs, and only after the password check, so the SPA no longer reveals which emails are HCPs.
  - `loginForMobile()` accepts HCP accounts only; archived HCPs and inactive users are refused.
  - Tokens are named `api-v2` (SPA, overridable with `token_name`) or `hcp-app` (mobile), never APP_KEY. No revoke-on-login, same as before.
- [x] `UserService::registerDevice()` / `forgetDevice()` handle the push device (a device belongs to one account at a time). The v2 rule stays: login stores the device only when `platform` is absent.
- [x] SPA `LoginController` (login, logout, change-password) and mobile `LoginController` (login, verify-token, logout, forgot-password) are thin adapters over `AuthService` / `UserPasswordService`, each keeping its own response contract.
- [x] **One password service.** The duplicate `UserService::changePassword` (no current-password check) and `resetPassword` (missing view) are gone.
  - `UserPasswordService` emails via `NotificationService` → `user.password-reset` → `PasswordResetMail`, tenant-branded and encrypted in the queue.
  - Before this, the missing view threw *after* the password had changed, which locked users out.
- [x] Removed the public duplicate `POST auth/change-password` (`UserController`, no current-password check).
- [x] `UserService::createUser()` now assigns roles by `type`. The name lookup asked for `'hcp'` but the seeded name is `'HCP'`, so **creating an HCP threw `RoleDoesNotExist`** (this is the Hiresome path).
- [x] Mobile login and forgot-password no longer reveal which emails are registered (the `exists:users,email` rule is gone). An unknown email gets the same response as a wrong password; forgot-password always says it was sent.
- [x] Deleted `RegisterController`, `SocialLoginController` and `VerificationController` plus the `Register`, `SocialLogin`, `VerifyOTP` and mobile `ResetPassword` requests.
- Note: `hcp/change-password` (HCPController) still needs rewriting in Phase 3, onto `UserPasswordService::changePassword`.

### Phase 2 — Common content & facility ✅
- [x] `ReferenceDataService` + `ReferenceDataInterface`/`Repository` (countries, states with `cities.suburbs`, cities, suburbs). A missing parent id means no filter, as in v2.
- [x] Taxonomy:
  - `CategoryService::active()`;
  - `ProfessionService::availableForHcp()` (excludes every profession the HCP holds, in any status; with `document` + `document_configuration.document`, `plan_list_order`);
  - `LevelService::activeFor()`.
  - `mark_up`, `facility_per_rate` and `admin_fees` are **hidden** from HCPs (v2 sent them).
  - `get-levels` without `profession_id` → records_not_found. (v2 matched `category_id IS NULL`, so it only "worked" when both ids were sent.)
- [x] `AppContentService::commonContent()`.
  - `configuration` is v2's `get_app_config()` column whitelist.
  - `dev_configuration` is `id, config_key, config_value`.
  - `lms` is `id, lms_url`.
  - `documents` keeps v2's paginator shape; **active** document types only.
  - A tenant with no supporting-page row → `supporting_page: null`. (v2 500'd.)
  - **File links, no app change needed:** every `image_path` base is now `""` and each supporting-page file field is a complete signed URL (12 h). The app's `base + file` concatenation still works. Files live under `storage_paths.supporting_pages` (`uploads/supporting-pages`). ⚠️ Legacy tenants' files must be copied there.
- [x] `get-total-shifts-and-hours-completed` → new `ShiftQueryService::completedTotals()` (read-only; `ShiftService` stays lifecycle-only). Counts status `'4'` **and** `'6'`; v2 counted only `'4'`, which would drop approved shifts.
- [x] `facility/` → `FacilityService::listForHcp()`.
  - v2's filters (`state_id`, `name`), relations and ordering.
  - **Location columns only.** v2 sent every HCP each facility's `card_number` / `card_cvv` / `stripe_customer_id`, contacts and emails.
  - `average_rating` is preloaded via `withAvg`: 3 queries instead of about 200.

### Phase 3 — HCP (`hcp/*`) ✅ (dashboard → Phase 4)
Contract extracted from v2 in full before the rewrite. `HCPController` is now thin over the new **`HcpProfileService`** (the HCP's self-service; every method takes the signed-in HCP's own model, so no other HCP's id is accepted).

- [x] **Validation shape corrected for all mobile requests.** v2's global handler overrode the FormRequests, so the app actually receives `422 {"status":false,"message":"Validation failed.","errors":{field:[…]}}`. `MobileFormRequest` now sends that shape, and Login / ForgotPassword extend it. (Phase 0 had copied the FormRequests' dead flat-list shape.) 401s now carry `status:false`.
- [x] Profile / personal details / registration details (TFN, ABN, reg no., CV) / account details / thank-you screen / availability.
  - **Omitted fields are left alone.** v2 wrote NULL, including wiping `cv`.
  - Image and CV go via `TenantFileStorageService` (`storage_paths.hcp_images` / `hcp_cvs`); the replaced file is deleted.
  - Profile and login payloads add signed **`image_url` / `cv_url`**. ❓ The app must switch to these: it built links from `hcp_path + #HCP{id}/…`, which the private bucket can't serve.
  - The "accept lower-level jobs" choice is **retired** (November 2026): shifts match the worker's exact level (`hcp_profession.level_id`). The app's `accept_lower_level_job` is still accepted and ignored; `show_accept_lower_level_job_checkbox` is always sent as 0; `accept_level_ids` is no longer read.
  - **Registration screen is server-computed** (`HcpDocumentService`). `update-app-registration-screen` only triggers a recompute, and is refused when the app claims `0` but the data disagrees.
  - `Hcp::hasCompletePersonalDetails()` **no longer requires `gender`**. The app never collected it, so app-onboarded HCPs would be stuck on screen 1. `gender` is now accepted optionally.
- [x] Documents → `HcpDocumentService::uploadDocument`.
  - Storage goes via `TenantFileStorageService`. The `hcp_documents` key resolves to the **same legacy path** (`uploads/hcps/#HCP{id}/documents`), so no files move; `#` is encoded per segment. `getDocumentUrl()` is now **signed** (the old plain URL was refused by the private bucket, which also affected the SPA).
  - Expiry-reminder ladder (60/30/7/1 days) is in the service, with the HCP's state timezone.
  - Notification expiry is **scoped to the HCP** (`NotificationInbox::expireForDocument`).
  - A re-upload is `pending-reapproval`.
  - Document types are checked: general vs profession, active, and configured for that profession. The HCP must hold the profession.
  - List responses add signed `document_url`.
  - Profession-document upload → `admin.hcp-document-uploaded` (inbox, legacy template `hcp-update-document`).
  - Dropped: v2's `edit_screen` `hcp_profession_request` / profession→pending side effects. Document approval + compliance now drives status.
- [x] Professions: list (with document counts), required documents, status, delete. **Ownership-checked** (`findForHcp`).
  - Status is **active ↔ inactive only**, so no self-approval.
  - Delete removes the rows and files, then re-syncs compliance.
- [x] Notifications inbox → `NotificationInbox::pageForHcp()`. Fixes v2's AND/OR precedence, which showed expired `all-hcp` rows.
- [x] Ratings → `RatingReviewInterface` / `RatingReviewRepository` + `RatingService` (Phase 4 adds `give-rating`). `average_rating` is preloaded: 1 query instead of up to 400.
- [x] OTP → `OtpService`.
  - Code is **never returned**, bound to user and address, 10-min expiry, 5 guesses, single-use, 5 sends/hour/address.
  - Email via `user.email-verification` (`VerificationCodeMail`, preview `/__mail/verification-code`).
  - SMS via new **`SmsChannel`** (Twilio REST, `TWILIO_SID` / `TWILIO_AUTH_TOKEN` / `TWILIO_NAME`). When unconfigured, the endpoint answers `sms_something_went_wrong` (v2 claimed success).
  - Endpoints are now signed-in only.
  - `send-email-verification`'s `unique` rule ignores the user's own address.
  - ❓ `data.otp` is gone from the send responses.
- [x] `change-password` → `UserPasswordService::changePassword`, which **checks the current password**. The wrong password comes back as a 422 `errors.current_password`.
- [x] Activity log → `HcpActivityService` (`lang/en/hcp_activities.php`), replacing `create_hcp_activity()`.
- [x] Deleted the request classes for `register` / `add-update-profession(-new)`.
- [x] **Migrations to run** (`php artisan tenants:migrate`):
  - `one_time_passwords` gets `target`, `attempts`, `consumed_at`.
  - `document_hcp.document_approval` gets `rejected`. The SPA's admin reject wrote a value the enum didn't have, so **every rejection failed**.
- ↪ `hcp/dashboard` moved to Phase 4 (route removed until then; it 500'd before too).

### Phase 4 — Shift (`shift/*`) + `hcp/dashboard` ✅
The v2 contract was extracted in full first. `ShiftController` is thin over `ShiftQueryService` (reads), `ShiftService` (every state change), `ShiftTrackingService` and `RatingService`.

- [x] **Reads:** new `HcpShiftInterface` / `HcpShiftRepository`. Every query is bound to the signed-in HCP.
  - `shift_status` lists keep v2's shapes:
    - `upcoming` → {upcoming, running}
    - `available` → {available, transfer, swap}
    - `completed` → {approved, completed}
    - `scheduled` / `running` / `cancelled` / `transferred` → a single paginator
  - **An unknown value is "not available".** v2 returned every shift in the tenant.
  - **`available` uses the same rules as broadcast + accept:**
    - live profession match (`FIND_IN_SET accept_level_ids`), facility blocklist, overlap with the HCP's live shifts;
    - broadcast radius to the facility **or client** site, with bound parameters (v2 concatenated coordinates into SQL);
    - not yet ended; not declined by this HCP.
    - v2's buddy-request gate (always empty now) is gone. Transfer and swap lists are empty pages.
  - Shift payloads **hide** facility money, admin fees, `notes_internal`, `booked_by` and `deleted_at` (`ShiftQueryService::HIDDEN_FROM_HCP`). Soft-deleted shifts are excluded.
  - `details` is visible only when the shift is the HCP's own, they have history on it, or it's open and they could accept it; otherwise v2's refusal messages. Keeps the `facility_duty_document` keys.
- [x] **Writes — ownership-checked:** `start`, `end`, `shift-tracking` and `give-rating` require `shift.hcp_id` = the caller; anything else gets "Shift not available." (v2 let anyone start or end any shift, and `end` creates the PayrollLine). ShiftService aborts → `200 {status:false, message}`; `start` failures keep v2's `data: []`.
- [x] `reject` → new **`ShiftService::decline()`**: open shifts only, recorded once, and the shift drops out of that HCP's available list (v2 kept showing it).
- [x] `shift-tracking` → **`ShiftTrackingService`**. Appends to the trail, and computes `hcp_reached` in **km**, including on the first ping (v2 compared metres to a km setting, and the first ping never counted). The response `distance` stays in metres, as in v2. The admin "reached" alert remains `ShiftGeofenceCheckJob`'s (sent once), so no duplicate.
- [x] `give-rating` → **`RatingService::rateFacility()`** (v2's always 500'd). Only for the HCP's own completed or approved facility shift, once, with ids taken from the shift, written as `unpublish`. One row per shift carries both sides.
- [x] `hcp/dashboard` → new **`HcpDashboardService`**. v2 keys and order are kept.
  - Earnings are `PayrollLine` gross (non-void) for the accruing pay period, the current week of it, and year-to-date. This fixes v2's week-total "fortnight" and zero weekends.
  - `payroll_cutoff_time` is padded (fixed in `PayrollRunService`).
  - `point_message` is null and `referral_and_earn` is "" (dropped features). `pending_profession_documents` comes from the document counts.
  - ❓ `config/accounts.php` doesn't exist, so payroll runs on `PayrollRunService`'s defaults: weekly, Thursday 17:00.
- [x] Requests for the dropped swap / transfer / buddy / cancel / payment endpoints are deleted.
- [x] **Found and fixed on the way:**
  - **Nine models' relations pointed at the non-existent `App\User`** (Shift, ShiftActivity, HcpActivity, HcpBlockTime, OneTimePassword, PayrollRun, AdminProfile, FacilityActivity, FacilityGroupSubAdmin). `find_shift()` eager-loads `activities.user`, so **`ShiftService::find()` — every SPA and mobile shift action — crashed** for any shift with activity rows. All now use `App\Models\User`, and `ShiftExportDefinition` imports it too.
  - `HcpProfessionRepository::assign()` never set `accept_level_ids`, so 1,649 of Smart Healthcare's HCP professions matched no levelled shift in broadcast or accept. It's now set on assign, with a backfill migration (`000003`).
  - `Facility` / `Hcp` `average_rating` preloads, avoiding N+1 on every list.
  - `HcpEligibilityService` compared `Carbon::now()` (UTC) with local shift times, so shifts could be accepted about 8h after they ended in Perth. It now compares in the site's timezone.
- ⚠️ Not fixed: `app/Http/Exports/*` declares `namespace App\Exports\…`, so it can't autoload. It's unused, unfinished export code.

### Phase 5 — Delete legacy ✅
- [x] The five mobile controllers were rewritten in place (no legacy copies remain). Request classes for every dropped endpoint are deleted; each remaining one extends `MobileFormRequest`.
- [x] **`app/Http/Helpers.php` deleted (1,473 lines).** A scan of the whole app (controllers, services, jobs, commands, blade views) found no remaining calls to any of its 60 functions; the only mentions are docblocks. Composer never autoloaded it. Break-time config now also goes through `GeneralConfigService::breakTime()` (was `BreakTimeConfig::find(1)` in ShiftService and PayrollLineService).
- [x] Unused traits deleted: `UploadImage`, `UploadFile`, `GetUserData`.
- [x] Verified after deletion: `composer dump-autoload`, all 200 `api/v2` routes register, views compile, and the Phase 2–4 smoke suites pass unchanged.
- [x] CLAUDE.md: new "Mobile (HCP app) API" section.
- Left for their owners (not mobile): `Spa\StoreHcpRequest` and `Spa\Shift\BroadcastSelectedRequest` are unreferenced; the `StatusJobToggleable` trait is unused; `app/Http/Exports/*` can't autoload (namespace/path mismatch).

## Endpoint map

| Route | Fate | Central service |
|---|---|---|
| `login` · `logout` · `verify-token` · `forgot-password` | rewrite | `AuthService`, `UserPasswordService` |
| `register` · `guest-login` | delete | — (template code) |
| `get-countries` · `get-states` · `get-cities` · `get-suburbs` | rewrite | `ReferenceDataService` (new) |
| `get-categories` · `get-professions` · `get-levels` | rewrite | `Taxonomy\*Service` |
| `get-common-content` | rewrite | `AppContentService` (new) |
| `get-total-shifts-and-hours-completed` | rewrite | `ShiftQueryService` (new, read-only) |
| `facility/` | rewrite | `FacilityService::listForHcp` |
| `hcp/register` | **delete** | — (Hiresome creates HCPs) |
| `hcp/personal-details` · `update-registration-number` · `update-account-details` · `update-thank-you-screen-status` · `update-available-job-status` · `profile` · `update-app-registration-screen` | rewrite | `HcpService`, `HcpOnboardingService` |
| `hcp/add-update-document` · `add-update-profession-document` · `documents` | rewrite | `HcpDocumentService` |
| `hcp/get-hcp-professions` · `get-profession-documents` · `update-hcp-profession-status` · `delete-hcp-profession` | rewrite (scoped) | `HcpService`, `HcpDocumentService` |
| `hcp/add-update-profession` · `add-update-profession-new` | **delete** | — (deprecated) |
| `hcp/notifications` · `hcp/ratings` | rewrite | new read services |
| `hcp/dashboard` | rewrite | ShiftService read + `PayrollLine` + `PayrollRunService` |
| `hcp/update-device-id` | rewrite | `UserService::registerDevice` |
| `hcp/send-/verify-email` · `send-/verify-mobile` | rewrite | `OtpService` (new) |
| `hcp/change-password` | rewrite | `UserPasswordService` |
| `hcp/refer-and-earn` · `get-my-buddy-shift` · `hcps/` | **delete** | dropped |
| `shift/` · `details` | rewrite | HCP shift read service (new) |
| `shift/accept` · `start` · `end` | rewrite (+ ownership) | `ShiftService` |
| `shift/reject` · `shift-tracking` · `give-rating` | rewrite | `ShiftService::decline`, tracking + rating repositories |
| `shift/payments` · `payment-details` | **delete** | — (transactions table dropped) |
| `shift/cancel` · `transfer` · `accept-/reject-/cancel-transfer-request` | **delete** | disabled for HCPs |
| `shift/*swap*` (5) · `create-buddy-shift-request` · `buddy-shifts` | **delete** | dropped |
