# ESP Removal + USP One-Time Gate — Implementation & Test Plan

> Sub-plan of [phase4-customer-flow-plan.md](phase4-customer-flow-plan.md).
> Source-of-truth diagram: [flow_customer.mermaid.md §2](flow_customer.mermaid.md).
> Business decision: 2026-05-12 — retire S5 ESP entirely; show S5b USP at most once per user.

## Scope

1. Remove the S5 ESP screen + state from `client_app`.
2. Rewire `VerifChoiceSheet` to go straight to a `usp_seen?` gate, then USP, then the original next step (S3a for verified, PickMethod for anon).
3. Add a `customers.usp_seen` boolean to the backend; expose on `/api/client/me`; add `POST /api/client/usp-seen` to set it.
4. Add a Riverpod `uspSeenProvider` backed by `SharedPreferences` with DB sync on login and on dismissal.
5. Update Maestro flows + add new flows for the gate behaviour.

Out of scope: changing USP copy/visuals; reordering USP relative to OTP (business accepted the cross-device first-view edge case).

---

## Build order

The work splits into 5 ordered stages. Backend lands first so the client has a real endpoint to call.

### Stage 1 — Backend: schema + read + write

**Files:**
- `backend/src/db/migrate.js` — append a Phase-4 ALTER block:
  ```sql
  ALTER TABLE customers ADD COLUMN IF NOT EXISTS usp_seen BOOLEAN NOT NULL DEFAULT FALSE;
  ```
- `backend/src/services/customer.service.js`
  - Add `usp_seen` to `CUSTOMER_SELECT` so every read includes it.
  - Add `markCustomerUspSeen(customerId)` — idempotent UPDATE that sets `usp_seen = TRUE` and returns the row via `CUSTOMER_SELECT`.
- `backend/src/routes/public/client.auth.routes.js`
  - Already returns the customer from `getCustomerById` on `/api/client/me`. The new column rides along automatically once it's in `CUSTOMER_SELECT`.
  - Add `POST /api/client/usp-seen` handler: requires JWT, calls `markCustomerUspSeen`, returns the updated customer. No request body needed.

**Acceptance:**
- New customer row has `usp_seen = false`.
- `POST /api/client/usp-seen` flips it; second POST is a no-op (still returns true).
- `/api/client/me` response includes `usp_seen` for both first-time and returning users.

### Stage 2 — client_app: `uspSeenProvider` + DB hydrate

**New file:** `client_app/lib/features/onboarding/usp_seen_provider.dart`
- Async-init `Notifier` (or `AsyncNotifier`) that:
  - On build, reads SharedPreferences key `usp_seen` (default false).
  - Exposes `bool get hasSeen` synchronously after init.
  - `Future<void> markSeen()`:
    1. Write `true` to SharedPreferences.
    2. If JWT is present (authProvider state is `AuthAuthenticatedData`), call `POST /api/client/usp-seen` via `ApiClient` — fire-and-forget with logging; don't block UX on the network call.
- Add `hydrateFromCustomer(Customer c)` — call from auth bootstrap (e.g. wherever `/api/client/me` is fetched and stored in `AuthAuthenticatedData`). OR-merge: if `c.uspSeen == true`, write `true` to SharedPreferences.

**Edit:** the auth notifier that already calls `/api/client/me` on app boot — add a call to `uspSeenProvider.hydrateFromCustomer(...)` after the response lands. (Per Explore: `auth_notifier.dart` has the `AuthAuthenticatedData` carrying the profile.)

**Acceptance:**
- Fresh install: provider returns `false`.
- After `markSeen()`: provider returns `true`; SharedPreferences key set; backend hit (if auth'd).
- Login on a fresh device where DB has `usp_seen=true`: provider returns `true` after auth hydrate completes.

### Stage 3 — client_app: rewire VerifChoiceSheet → USP gate

**Edit:** `client_app/lib/features/auth/widgets/verif_choice_sheet.dart`
- Replace `routeForVerifChoice()` body. New logic (pseudo):
  ```dart
  final seen = ref.read(uspSeenProvider).hasSeen;
  if (choice == VerifChoice.verifWA) {
    if (seen) context.push('/auth/register');   // straight to S3a
    else      context.push('/onboarding/verif/usp');
  } else {
    if (seen) context.push('/payment/method-pick');  // straight to PickMethod
    else      context.push('/onboarding/anon/usp');
  }
  ```
  (Exact target routes per existing `router.dart` registrations.)

**Edit:** `client_app/lib/features/onboarding/screens/usp_screen.dart`
- On the primary "Continue" / next CTA tap, `await ref.read(uspSeenProvider.notifier).markSeen()` BEFORE navigating to the next route.
- The existing post-USP routing (verified → S3a, anon → PickMethod) stays — the `markSeen()` call just precedes it.

**Edit:** `client_app/lib/features/auth/screens/otp_screen.dart` (or wherever the OTP-Blocked popup lives) — the fallback to anon path. Currently it pushes ESP; change to the same USP-gate logic above (`uspSeenProvider.hasSeen ? PickMethod : USP`).

**Acceptance:**
- First-time verified flow: VerifChoice "verif WA" → USP (with markSeen on continue) → S3a.
- Second-time verified flow: VerifChoice "verif WA" → S3a directly.
- First-time anon flow: VerifChoice "tanpa verif" → USP → PickMethod.
- Second-time anon flow: VerifChoice "tanpa verif" → PickMethod directly.
- OTP-Blocked fallback respects the gate.

### Stage 4 — client_app: delete ESP

This is the cleanup step; it intentionally runs *after* the new gate is wired so we never have a moment where the build is broken.

**Delete:**
- `client_app/lib/features/onboarding/screens/esp_screen.dart`
- `client_app/lib/features/onboarding/esp_state.dart` (the two `espSelectionProvider` / `espSkippedProvider`)

**Edit:** `client_app/lib/router.dart`
- Remove the two `/onboarding/verif/esp` and `/onboarding/anon/esp` `GoRoute` entries.
- Leave the `/onboarding/*` redirect carve-out intact (USP still uses it).

**Verify:** `flutter analyze` clean — no dangling imports, no orphan references to `EspTopic`, `espSelectionProvider`, `espSkippedProvider`.

### Stage 5 — Tests

Detailed below in the **Test plan** section. Updates:
- Existing Maestro flows `02_onboarding_verified.yaml` + `03_onboarding_anon.yaml` need to drop ESP steps and add gate-aware assertions.
- New Vitest cases for the migration + service + route.
- New Maestro flow for the USP-skip-on-second-run case.

---

## Data contract

### `customers.usp_seen` (new column)
- Type: `BOOLEAN NOT NULL DEFAULT FALSE`
- Set true only on `POST /api/client/usp-seen` or via direct backfill.
- Never read by anything except `/api/client/me` and the dedicated POST handler.

### `POST /api/client/usp-seen`
- Auth: JWT required (same middleware as `/me`).
- Request: empty body.
- Response: 200 with the updated customer object (same shape as `/me`).
- Errors: 401 on missing/invalid JWT; 404 if customer row doesn't exist (shouldn't happen in normal flow).
- Idempotent: calling twice is fine.

### `/api/client/me` (extended)
- Existing payload + `usp_seen: boolean`.

### SharedPreferences key (client)
- Key: `usp_seen`
- Value: `bool` (default `false`).
- Owned by `uspSeenProvider`; no other code reads/writes it directly.

---

## Test plan

### Unit tests (Vitest, in `backend/test/`)

**New file:** `backend/test/customer.usp-seen.test.js`

| # | Test | Setup | Assert |
|---|------|-------|--------|
| 1 | Migration default | Insert customer via `createCustomerWithIdentity` with no usp_seen | `getCustomerById(...).usp_seen === false` |
| 2 | `markCustomerUspSeen` flips flag | Customer with `usp_seen=false` | After call, row has `usp_seen=true`; return value's `usp_seen` is `true` |
| 3 | Idempotent | Customer with `usp_seen=true` | Calling again still returns `usp_seen=true`; no error |
| 4 | `POST /api/client/usp-seen` requires auth | No `Authorization` header | 401 |
| 5 | `POST /api/client/usp-seen` happy path | Authed customer, `usp_seen=false` | 200, response `usp_seen=true`, DB row `usp_seen=true` |
| 6 | `/api/client/me` includes flag | Authed customer | Response has `usp_seen` key (true or false) |

Target: 6/6 green via `npm test` in `backend/`.

### Flutter widget/integration tests

These are not currently a major surface in this repo (Maestro is the main client-side gate). Skip Flutter-level widget tests unless something breaks.

### Maestro flows (`client_app/.maestro/flows/`)

**Existing flow updates:**

- **`02_onboarding_verified.yaml`** — first-time-verified path
  - Remove: any `assertVisible` / tap targets on ESP chips ("Hubungan", "Lewati").
  - Update sequence: VerifChoiceSheet → tap "Verif WA Rp2k" → **assert USP visible** → tap continue → S3a WhatsApp input → ...
  - Add at the start: `runScript: scripts/reset_phone_and_local.js` so the run always starts from a clean state (no `usp_seen` in DB, no SharedPreferences value).

- **`03_onboarding_anon.yaml`** — first-time-anonymous path
  - Remove: ESP chip taps / "Lewati" tap.
  - Update sequence: VerifChoiceSheet → tap "tanpa verif Rp5k" → **assert USP visible** → tap continue → `/payment/method-pick`.
  - Add reset script at start.

**New flows / deferrals:**

The "second-run skip" and "DB hydrate" cases turned out hard to script in Maestro: once a customer logs in (anonymously or with phone), the app is in an authenticated session and the only path back to `VerifChoiceSheet` (where the gate is consulted) is via logout, which clears local SharedPreferences too. Returning-user CTAs go through `BestieChoiceSheet`, not `VerifChoiceSheet`, so the gate is never re-evaluated on the returning path.

What we *do* have:
- Flows `02` and `03` (first run, USP visible, ESP not visible) — covered above.
- Vitest 8/8 covers the backend: column default, `markCustomerUspSeen`, route 401/200/403, `/me` payload before + after the flag flips.

What stays in **manual smoke** (operator-driven, documented in the next section):
1. Local flag persists across `stopApp/launchApp` — `adb shell pm clear` should NOT happen between runs; verify USP is skipped on second walk through VerifChoice.
2. DB hydrate — pre-seed a customer row with `usp_seen=true` via control center or psql, sign in via phone OTP, verify USP is skipped on first ever appearance.
3. OTP-blocked popup — exit via "lanjut tanpa verif" still lands at `/payment/method-pick`. (Pre-USP-gate this was a direct redirect; the gate doesn't fire on this path because USP has already been shown/skipped upstream.)

**Known blocker (2026-05-12):** flows `02` and `03` had their ESP steps removed and USP gate assertions added, but the runtime can't currently execute them end-to-end. The new `SHome1st` view (Phase 4 Stage 9) wraps the home column in a single Semantics node, so the `"aku mau curhat"` CTA's `text` attribute reads as empty (its label only lives in the parent's merged `accessibilityText`). Maestro's `text:` matcher can't locate the button, blocking the entire flow before USP is even reached. This is a Stage-9 accessibility regression, not an ESP/USP issue — the flow YAML edits are correct and will pass once SHome1st's CTA is wrapped in its own `Semantics(label: '…', button: true)` or given a `Key`.

### Manual smoke (real device)

After Maestro is green on emulator, hand-run on the physical Samsung:
1. Fresh install → "aku mau curhat" → name → VerifChoice → verif WA → USP (visible) → S3a → OTP → S6 paywall.
2. Force-stop → relaunch → "aku mau curhat" → VerifChoice → verif WA → S3a (USP skipped) → ...
3. Same but anon path.
4. Uninstall + reinstall → login with same phone → "aku mau curhat" → verify USP skipped (DB hydrate proved end-to-end).

### Visual regression

`flutter analyze` clean. Spot-check VerifChoiceSheet, USP screen, OTP-Blocked popup in the emulator — no broken navigation, no leftover ESP icon/copy.

---

## Rollout & migration

- Migration is additive (NOT NULL DEFAULT FALSE), safe to run on existing DB. All existing customers come out with `usp_seen=false` — meaning every returning user will see USP one more time on next "aku mau curhat". Business accepted this.
- No backfill needed.
- Cloud Run rollout: backend first (migration runs on boot), then client_app build.

## Risks

1. **Provider init race** — if `uspSeenProvider` is read before SharedPreferences finishes loading, the gate could return false (default) and show USP unnecessarily. Mitigation: use `AsyncNotifier` and gate the VerifChoice navigation on the loaded state, or read SharedPreferences synchronously at app boot before the first VerifChoice render.
2. **Network failure on `markSeen()`** — if `POST /usp-seen` fails after local flag is set, DB stays false. Next session uses local (still true) so user UX is fine. On a new device, USP shows once more. Acceptable per the cross-device edge case decision.
3. **Two-tab race** — not applicable; mobile app.

---

## Done criteria

- [ ] `customers.usp_seen` column exists in dev DB.
- [ ] `POST /api/client/usp-seen` returns 200 for authed call.
- [ ] `/api/client/me` payload includes `usp_seen`.
- [ ] Vitest 6/6 green on the new test file.
- [ ] No `esp_screen.dart` / `esp_state.dart` / `/onboarding/*/esp` routes in client_app.
- [ ] `flutter analyze` clean on client_app.
- [ ] Maestro: `02`, `03`, `04`, `05`, `06` green on the Client_Phone emulator.
- [ ] Manual smoke 1–4 above pass on physical device.
- [ ] `TECH_DEBT.md` "S5 ESP screen retired" entry closed / removed once cleanup lands.
