Commit Graph

76 Commits

Author SHA1 Message Date
e4bffe1a71 Extension request: WS→FCM fallback + chat-recovery on connect
Today the customer's "Perpanjang" only reaches the mitra via session-
scoped WS. If the mitra is on Home/Undangan, in a different session, or
backgrounded, the WS send no-ops and the 10s safeguard timeout fires
auto-reject (or auto-approve if the mitra happens to also have an
active general WS, depending on config) — either way the mitra never
saw the request.

Backend:
- extension.service.js::requestExtension now falls back to FCM via
  notification.service when the mitra isn't on the session WS. Mirrors
  the pairing notifyMitra pattern (Curhat Baru). Customer display name
  is pulled into the session lookup for the FCM body.
- shared.chat.routes.js: /chat/:sessionId/info now returns
  pending_extension (extension_id, duration_minutes, price,
  requested_at, expires_at, timeout_seconds) so the chat screen can
  rehydrate the accept/reject UI after a cold-start FCM tap. expires_at
  is derived from requested_at + extension_timeout_seconds config.

Mitra app:
- mitra_chat_notifier.dart::connect parses pending_extension from /info
  and seeds MitraChatConnectedData.extensionRequest — the existing
  _buildExtensionView renders unchanged.
- notification_service.dart::_navigateFromMessage handles
  type=extension_request → pushes /chat/session/<id>. Composes with
  the new /info pending_extension to bring the mitra straight into the
  accept/reject view.

Verified end-to-end on dev backend (FCM call returned sent=true; /info
returns pending_extension when within timeout window). Visual delivery
on emulator-5556 deferred — API 24 AVD queues FCM 5-30 min per
feedback-emulator-avd-versions.

Out of scope (follow-ups):
- Customer-side FCM for EXTENSION_RESPONSE (accepted/rejected/timeout)
- Perpanjang tab list endpoint + Flutter provider + UI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:24:40 +08:00
368d18a0bf Mitra: regression coverage for back-press-during-session-ended
Verified the 2026-05-15 disconnect() fix end-to-end on emulator-5556:
mitra logs in → online → accepts blast → backend force-expires →
goodbye composer renders → back-press → lands on Bestie Home with
online status preserved, zero flutter:E in logcat.

- ts-mitra-3-08-back_press_after_session_expired_no_red_screen.yaml
  codifies the repro for Maestro. Extends ts-mitra-3-04 with the
  back-tap + home-assertion + red-screen guard.
- mitra_app/CLAUDE.md adds a Pitfall section beneath the existing
  "no ref in dispose" rule: never mutate notifier state synchronously
  from deactivate() cleanup — wrap in
  SchedulerBinding.addPostFrameCallback or Riverpod throws "Tried to
  modify a provider while the widget tree was building" during the
  back-nav teardown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:32:07 +08:00
34a8f7154e gitignore: match agent-memory at any depth
The previous `.claude/agent-memory/` pattern only matched at repo root.
backend/.claude/agent-memory/ was still showing as untracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:15:26 +08:00
fbc94daac7 Mitra Bestie §1–§3: shell + Undangan + popup + chat polish
Brings the mitra app to figma-bestie parity for Home (§1), Undangan
inbox with Curhat Baru + Perpanjang tabs (§2), and the incoming-popup
+ active-chat flow (§3). Home now lives inside a StatefulShellRoute
with BestieTabBar so Profil + Undangan + Home share one shell.

- Shell: features/shell/ (StatefulShellRoute, BestieTabBar, 3 branches)
- Undangan: features/undangan/ — Curhat Baru reads
  chatRequestProvider.pendingInvites; row Terima delegates accept to
  the notifier and ChatRequestOverlay owns nav (no double-push).
  Perpanjang tab stubbed (empty state) until backend exposes
  pendingExtensionsProvider.
- Profil: features/profile/ — Bestie-styled stub
- Home: refactored to body-only (shell owns chrome)
- Popup: chat_request_overlay + chat_request_notifier updated to
  serve the list rows, not just the modal
- Chat: mitra_chat_screen polish
- Theme: accentAmber tokens for the Perpanjang tab + halo_orb widget
  (loading spinner used by undangan list states)
- Login: replace broken GoRouterState location guard with
  _expectOtpPush flag — was stacking duplicate /otp pages on OTP
  resend (see project-otp-nav-bug-fixed-2026-05-21)

Maestro:
- 17 new flows under .maestro/flows/ts-mitra-{1,2,3}-* covering home
  online/offline variants, undangan empty/populated/tolak states,
  popup curhat-baru → accept → chat → ended banner, plus popup
  dismiss/expire/cancelled edge cases
- 4 new §A OTP flows (07/08/09/10) for invalid/mismatch/expired/cooldown
- Helper scripts: force_mitra_online/offline, force_pairing_timeout,
  force_session_expires_at, delete_mitra_status_row,
  customer_blast_now (js), customer_cancel_latest_blast
- Backend: POST /internal/_test/delete-mitra-status-row supports the
  "fresh mitra with no status row" test setup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:14:30 +08:00
fcb8eaa505 App ID + launcher icon rename: halobestie.* → mybestie
- Customer: com.halobestie.client.client_app → com.mybestie
- Mitra:    com.halobestie.mitra.mitra_app  → com.mybestie.mitra
- iOS bundle IDs renamed to match (no .clientApp/.mitra camelCase legacy)

Mechanical rename touches Android build.gradle/Manifest/MainActivity
package, iOS pbxproj/Info.plist bundle IDs, Firebase configs
(google-services.json + GoogleService-Info.plist + firebase_options.dart),
new HaloBestie/Mitra launcher icons via flutter_launcher_icons (pubspec
config + adaptive-icon resources + AppIcon imageset), and the appId
references in every customer maestro flow + both .maestro/config.yaml
files. brandLogoBg (#FF699F) added to halo_tokens for the launcher pink.

Followup: re-register apps in Firebase consoles using the new package IDs;
strategy memo at project-firebase-env-strategy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:13:47 +08:00
9696eadeaf Mitra §A: pre-home (S3a/S3b/AccountInactive) + design system + Bestie Home
- Port halo_tokens + halo_theme + HaloButton to mitra_app (rose palette,
  Bricolage display, Poppins body, JetBrainsMono).
- Build S3a Input WhatsApp (figma-bestie BestieS3 first half) with
  +62 chip, leading-zero/62 normalization, allow '+' in input.
- Build S3b OTP verification (6-digit, 60s resend timer, attempts hint,
  Focus(canRequestFocus:false) for maestro inputText compat) with full
  error branching (CODE_MISMATCH, OTP_EXPIRED, OTP_USED, ATTEMPTS_EXCEEDED,
  WRONG_FLOW, ACCOUNT_INACTIVE).
- Add AccountInactive terminal screen for is_active=false mitras.
- Typed MitraAuthError with Indonesian-first localized messages +
  retryAfterSeconds passthrough.
- Rebuild home_screen.dart to match figma BestieHome (greeting + status
  card + Ganti Status CTA + Pengingat + 2-tile dark grid).
- Backend: POST /internal/_test/seed-mitra (idempotent) and
  PATCH /internal/mitras/:id (display_name update).
- Control center: inline Edit Nama on mitras row + expandable inline log
  table under clicked mitra (vs old below-table panel).
- 5 maestro flows ts-mitra-A-01/03/04/05/06 covering invalid input, happy
  path, account inactive, phone-format normalization, and the back-to-S3a
  regression. All green.

Plan + memory documented in:
- requirement/phase4-mitra-prehome-plan.md
- requirement/flow_mitra.md / flow_mitra.mermaid.md §A

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:01:28 +08:00
ad02ee252d Phase 4 §1/§5: notif banner detection on API <33 + chat-delivery WS→FCM lifecycle
§1 notif banner: permission_handler v11 returns granted unconditionally
for Permission.notification on Android <13 because POST_NOTIFICATIONS
didn't exist as a runtime permission. Result: SHome1st amber "notifikasi
off" banner never showed on API 24-32 even when the user toggled
notifications off in Settings → Apps. Add a
NotificationManagerCompat.areNotificationsEnabled() pre-check via
flutter_local_notifications (works from API 19+) so the banner reflects
the real OS state on older Android.

§5 chat delivery: the contract is "WS when foreground, FCM when
background", but the previous build only honoured (1) — Android keeps
the TCP socket alive after the Dart isolate is paused, so backend's
`socket.readyState === 1` check returned true and FCM never fired.
Fix has five parts (all required together):

 1. Customer-side lifecycle observer in client_app/main.dart closes
    chatProvider's WS on paused/detached, reconnects on resumed.
 2. `_appPaused` gate in main.dart suppresses the activeSessionProvider
    listener's auto-reconnect (15s poll in active_session_notifier
    would otherwise re-open the WS the next tick after the observer
    closed it — defeating the fallback).
 3. Mitra-side lifecycle observer in mitra_app/main.dart stashes
    `_pausedChatSessionId`, calls mitraChatProvider.disconnect(), and
    re-issues connect(saved) on resumed.
 4. MitraChat gains a `_connectedSessionId` field + getter so the
    observer in step 3 can read it back across disconnect (disconnect
    clears it; the next connect overwrites it).
 5. SearchingScreen resets pairingProvider when entering with a new
    draft.paymentId — previously it retained PairingActiveData with
    the *old* sessionId after a session ended, and the next pairing
    flow navigated straight to that completed session showing
    "Sesi sudah berakhir".

Backend additions under /internal/_test/* for assertion harness:
inspectSessionWsState + GET /ws-connection-state,
POST /send-chat-message-as-mitra (with delivered_via),
POST /send-chat-message-as-customer (with delivered_via),
POST /send-fcm-chat-message (raw FCM dispatch).

Maestro coverage:
 - ts-customer-05-01: mitra → customer message when customer is
   backgrounded → delivered_via=fcm.
 - ts-customer-05-02: customer → mitra message when mitra is
   backgrounded → delivered_via=fcm.
 - ts-customer-01-01: §1 notif-denied banner on home. Documented
   precondition: mitra must be force-stopped or backgrounded on the
   chat screen before 05-02 runs (Maestro can only drive one --udid
   per run; mitra-side lifecycle observer end-to-end is deferred).

Helper scripts under client_app/.maestro/scripts/:
inspect_ws_state.js, assert_ws_state.js,
send_chat_message_as_mitra.js, assert_delivered_via.js (takes
SENDER=mitra|customer to route to the matching backend endpoint).

README_section_05.md documents the test plan, helper scripts, and the
deferred mitra-side maestro driving. Both apps tested manually on
API 28 AVDs where FCM delivery is sub-second; API 24 has 5-30 min
heartbeats that make it impractical for FCM-related testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:50:46 +08:00
093256ff7d Phase 4 §2 + §1/§4: OnboardingIntent post-OTP routing + test naming + register-screen overflow
Spec §2 (flow_customer.mermaid) routes post-OTP based on user-lookup +
has_transacted, but the implementation previously dumped every OTP
success on /home. Introduce `OnboardingIntent` provider: set to
`onboarding` by routeForVerifChoice's verified branch (the "aku mau
curhat" transaction journey), set to `recover` by SHome1st's masuk →
banner. Router redirect on AuthAuthenticatedData+isAuthRoute consumes it:
`onboarding` → /payment/entry (dispatches S6 paywall vs PickMethod via
first_session_discount.eligible); `recover` → /home. Intent is reset in
/payment/entry's initState so subsequent masuk → flows don't inherit it.

auth_notifier.verifyOtp uses .copyWithPrevious on AsyncError so
valueOrNull retains AuthOtpSentData/AuthAnonymousData through OTP
failures — required for the OTP-blocked recovery path
(/onboarding/anon/method → /payment/method-pick) to clear the global
redirect without bouncing to /home. Router also extends the
isAuthRoute/isOnboardingFlow carve-out to AuthOtpSentData.

Maestro tests adopt `ts-<app>-<NN>-<MM>-<descriptor>.yaml` convention:
NN = mermaid section, MM = sub-flow index. New ts-customer-02-01..05
cover the §2 branches (verified brand-new → S6, existing-no-tx → S6,
existing-tx → method-pick, OTP-blocked → method-pick, anonymous first-
timer → method-pick); deferred 02-06/07/08/09 documented in
README_section_02.md. TS-07 → ts-customer-02-10 (masuk → recovery);
TS-01..06 → ts-customer-04-01..06 (§4 returning-user). Shared
onboarding_new_user_verified.yaml subflow extracted.

Register screen's body Column now uses LayoutBuilder + SingleChildScrollView
+ ConstrainedBox + IntrinsicHeight so the keyboard-open layout no
longer overflows by 1.3 px (verified visually).

Spec prose updated at flow_customer.mermaid §2 to describe the
intent-driven routing + login-vs-transaction divergence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:50:04 +08:00
938954bbe8 Phase 4 plan: refresh status header (stages 0-10 + §4 migration shipped)
Header was dated 2026-05-10 and described stages 0-8 with Stage 9 in
progress. As of 2026-05-18 Stage 10 (chat tab), the §4 payment-before-pair
migration (Stages 5.1/5.3/5.4), the legacy /payment retirement, and the
TS-01..TS-07 Maestro suite are all on master. Older notes preserved
under "Post-Stage-8 corrections".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:57:19 +08:00
93fa5f113a Test: TS-07 returning user with existing display_name skips set-name
Inverse coverage for the auth path: TS-01..TS-06 all wipe the customer
row (drop_customer=true) so every OTP path lands on the new-user
set-name branch. TS-07 instead seeds an existing identified customer
(phone + display_name + is_anonymous=false) and verifies the OTP
sign-in returns the existing row unchanged via
resolveCustomerForIdentity branch 1, so /auth/set-name is never shown.

Adds:
* /internal/_test/seed-customer endpoint — upserts a customer with
  phone + display_name + is_anonymous=false.
* client_app/.maestro/scripts/seed_customer.js helper.
* client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml.
* TS-07 scenario doc + coverage-map row in
  requirement/phase4-customer-flow.md.

The flow asserts the "halo, <name>" greeting on the returning-user home
variant (identified users always land on _SHomeReturningView regardless
of chat history) plus an explicit notVisible on "Siapa namamu" as a
belt-and-braces check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:40 +08:00
e09f76ceb6 Phase 4 §4: payment-before-pair for returning users + Maestro suite
Stages 5.1, 5.3, 5.4 of the returning-user flow rework. All three §4
entry paths now require payment BEFORE pairing, matching the updated
mermaid spec.

* Spec (requirement/flow_customer.mermaid.md §4): payment block converges
  three call-sites (bestie-yang-udah-kenal-online, bestie-baru,
  offline-popup → cari bestie lain). PairRoute dispatches lama → targeted
  pair, baru/cari-lain → §3 blast. §3 retains its post-payment-shared
  contract.

* Stage 5.1 (client_app): PaymentDraft carries targetedMitraId +
  topicSensitivity. bestie_history_list seeds the draft + pushes
  /payment/entry (was legacy /payment). searching_screen branches on
  draft.targetedMitraId for blast-vs-targeted dispatch.
  payment_entry uses resetExceptTarget(); bestie_choice_sheet + home
  _onCurhatBestieBaruPressed call explicit reset() before push so
  the keepAlive draft can't leak stale targeting into a blast.

* Stage 5.3 (client_app): new BestieOfflineVariant.prePayReturning.
  Bestie-history-list _BestieRow splits tappable from dim so offline
  rows render dimmed but route taps into the popup. CTA "cari bestie
  lain" resets the draft + pushes /payment/entry.

* Stage 5.4 (client_app): deleted legacy /payment route,
  payment_screen.dart, payment_notifier.dart(+.g.dart). router cleaned.

* Tests (requirement/phase4-customer-flow.md + client_app/.maestro/):
  six Maestro flows TS-01..TS-06 covering every §4 branching point,
  all passing end-to-end. Shared onboarding prelude under
  .maestro/subflows/. New helper scripts: accept_latest_pending,
  force_mitra_offline, force_other_mitra_online,
  reset_all_mitras_online, mitra_accept_latest_internal. New backend
  _test endpoints to match. /reset-phone now cascade-deletes
  customer_transactions (FK was blocking). /force-pairing-timeout
  branches targeted (RETURNING_CHAT_TIMEOUT via
  expireTargetedPairingRequest, now exported) vs blast (PAIRING_FAILED).
  seed_history_session also outputs MITRA_NAME_RE (regex-escaped) for
  reliable selectors against display names containing regex specials.

* mitra_app: dispose-during-deactivate guardrail for back-press on the
  mitra chat screen after the customer's goodbye message. Pending real
  emulator repro verification (carried over from 2026-05-15).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:25:15 +08:00
1c9d81d81d Pricing: migrate from app_config JSON to relational tables
Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*`
keys in app_config with dedicated `pricing_tiers` and `pricing_promotions`
tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes)
natural-key constraint, optimistic-lock via `updated_at` token returning 409
STALE_WRITE on conflicts. Every mutation writes a history row capturing the
operator (changed_by from request.auth.userId) and change_kind.

CC SettingsPage replaces the JSON-textarea editors with per-row tables —
add / edit / soft-delete / reactivate / reorder, plus a buffered first-session
discount form with the same optimistic-lock contract. `minutes` and `mode` are
read-only on edit since they form the natural key; operators soft-delete and
recreate to change duration.

Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local
`readDiscountConfig` that still read from app_config — would have silently
fallen to hardcoded defaults once the legacy rows were deleted. Now reads from
pricing_promotions via the shared service helper, so CC edits to the first-
session discount affect actual payment pricing on the next request.

Customer-facing GET /api/client/chat/pricing shape unchanged (id values are
now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so
no app changes needed). 27 new backend tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:12:11 +08:00
a09f37135c Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail
Chat-screen performance (customer + mitra):
- Parent screens have zero `ref.watch` — only `ref.listen` for side effects
- Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split
  into narrow `.select` consumers (mode, sensitivity, timer)
- Per-second timer ticks routed to dedicated providers
  (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`)
  so WS `session_tick` frames don't invalidate the rest of the chat state

Dispose-in-ref bug fix:
- `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` —
  ref-using cleanup moved from `dispose()` to `deactivate()`. Modern
  Riverpod invalidates `ref` the moment `dispose()` runs; the resulting
  silent error corrupts the widget-tree finalize and the next screen
  appears frozen
- `halo_lints` package added at repo root with `no_ref_in_dispose` rule
  to catch this pattern in CI / IDE analysis
- `custom_lint` activated in both apps' `analysis_options.yaml`
  (was installed but never wired in — also brings `riverpod_lint`'s
  `avoid_ref_inside_state_dispose` online)
- CLAUDE.md Pitfalls section added to client_app + mitra_app

Phase 4 §3 retryable blast-failure (Option A):
- Backend `expirePairingRequest` + all-rejected use
  `recordIntermediateFailure` instead of `failPaymentSession` so the
  payment session stays `confirmed` for re-blast
- WS `pairing_failed` payload carries `is_terminal: false` on the
  retryable paths; client parses the flag and exposes `retryBlast()`
- "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment
- Pairing service test updated to reflect the new semantics

Customer waiting-payment screen navigation patch:
- `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback`
  redundancy after a release-mode bug where polling stopped but
  `context.go` never fired, leaving the screen visually stuck on
  "menunggu pembayaran"

See requirement/resume-2026-05-15.md for next-day pickup checklist
(mitra release rebuild + S21 Ultra install + retest is the gating item).

Bundles unrelated in-flight Phase 4 §2.x work that was already on disk
(ESP screen removal, USP one-time gate scaffolding, bestie-availability
public route, OTP service edits, Maestro flow tweaks) — kept together
to avoid a partial-rebase mess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:12:34 +08:00
a48f108fc0 Phase 4 §2.1: anonymous → existing-user merge breadcrumb
Adds `customers.account_belongs_to UUID NULL` and refactors customer
sign-in (phone/Google/Apple) so an anon row that re-verifies into an
existing customer no longer 409s. Instead the anon row stays intact
with a breadcrumb pointing at the real customer; tokens are issued
for the existing user. Actual data reconciliation onto the existing
row (chat_sessions, customer_transactions, payment_sessions,
pairing_failures) is deferred.

Backend
- migrate.js: ADD COLUMN account_belongs_to UUID REFERENCES customers(id)
  ON DELETE SET NULL.
- customer.service.js: stampAccountBelongsTo helper; account_belongs_to
  exposed in CUSTOMER_SELECT.
- auth.service.js: new shared resolveCustomerForIdentity (4-case logic);
  normalizeIdentityConflict + IDENTITY_ALREADY_LINKED 409 deleted;
  completeCustomerPhoneSignIn / signInWithGoogle / signInWithApple all
  route through the shared helper.
- client.auth.routes.js: new resolveAnonymousCustomerId picks the anon
  prefix ONLY from a verified Bearer JWT — closes the UUID-leak attack
  where a tamper-able body field could mis-route someone else's
  transactions. /otp/verify, /google, /apple all use it; the body field
  `anonymous_customer_id` is no longer accepted on any of them.
- test/services/auth.service.test.js: 9 Vitest cases covering phone +
  Google + Apple, all 4 logic cases + multi-merge accumulation.

Customer app
- auth_notifier.dart::verifyOtp: drop `skipAuth: true` and the dead
  body field so ApiClient auto-attaches the anon's Bearer from
  AuthBridge. Survives the AuthOtpSentData state transition (the
  earlier `_currentAnonymousCustomerId()` state-drop bug is bypassed by
  sourcing the id from the bridge instead of state).
- Google + Apple client paths remain unchanged (gated on provider
  creds; mirror this fix when wiring lands).

Docs
- flow_customer.mermaid.md: new §2.1 sub-section with the merge
  diagram, schema note, replaces-current-behaviour paragraph, and
  Bearer-only security callout.
- phase3.4-testing.md: §1.5 line 76 simplified (no more per-path
  split); new §1.5.1 with the 5-step operator scenario + DB invariants
  + curl recipe + Vitest pointer; new §1.5.2 covering Google/Apple
  parity (deferred client work flagged).

Verification (against live dev backend, before this commit):
- Vitest: 9/9 in auth.service.test.js; 49/51 overall (2 unrelated
  pre-existing failures in session-timer.service.test.js).
- Operator Node smoke: 14/14 in the §1.5.1 scenario; 11/11 in the
  Bearer-precedence cases.
- Real-device UI walkthrough on SM-A530F still pending — see resume
  memory `project_phase4_2_1_resume_test`.

Sister WIP bundled in migrate.js + customer.service.js: `usp_seen`
column + `markCustomerUspSeen` helper (Phase 4 USP one-time gate, was
already uncommitted in the working tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:57:53 +08:00
22b10c4bbf Phase 4 Stage 10 follow-up: restore BestieHistoryList picker for §4 curhat-lagi
The original Stage 10 plan retired chat_history_screen.dart on the
assumption that the new Chat tab Selesai sub-tab replaced it. That was
wrong: Figma has two distinct screens — `extras.jsx::SChatList` (the
Chat tab, browse-only) and `v4.jsx::BestieHistoryList` (the picker for
mermaid §4 returning-user curhat-lagi). They serve different purposes
on row tap: Selesai opens transcript, BestieHistoryList picks a past
bestie for targeted-pair.

Restoring BestieHistoryList at a new home:

- New screen `features/home/screens/bestie_history_list_screen.dart`
  matching Figma `v4.jsx::BestieHistoryList`:
    appBar title "bestie kamu sebelumnya"
    subtitle "{N} bestie yang pernah nemenin kamu"
    row: orb + "bestie {name}" + ONLINE pill + sessions count + last
         date + topic + → arrow
    row tap (online) → /payment with targetedMitraId (Stage-3 flow)
    row tap (closing-grace) → /chat/session/$id to finish goodbye
    row (offline) → dimmed, tap disabled

  Drops the per-row "curhat lagi" secondary button — the row tap IS the
  pick action now (cleaner, matches Figma).

- New route `/bestie/history` in router.dart; cleanly separated from the
  /chat/* family (which is now exclusively the Chat tab).

- BestieChoiceSheet "bestie yang udah kenal" re-pointed from /chat to
  /bestie/history.

- Stage 8 Maestro flow `08_returning_targeted.yaml` updated to assert
  the new screen title + tap the row by name (uses output.MITRA_NAME
  from the seed_history_session script).

- TECH_DEBT entry retired (curhat-lagi entry point restored). New
  TECH_DEBT entry tracks the still-pending wire-up of the Bestie
  Offline Popup variant for offline-row tap per mermaid §4.

flutter analyze clean (one pre-existing widget_test scaffolding error
unrelated to Stage 10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:26:57 +08:00
1908e98012 Phase 4 Stage 10 Maestro: 09_chat_tab.yaml + seed-pending-payment endpoint
Closes the Stage 10 acceptance criterion §10.11 #13 (Maestro coverage).

- New dev-only `POST /internal/_test/seed-pending-payment` — inserts a
  payment_sessions row in `pending` status with expires_at 20m out, so
  the Pembayaran sub-tab has a deterministic row to render. Body
  accepts { phone, isExtension?, amount?, durationMinutes?, mode? }.
  Gated on NODE_ENV != 'production' like the other test routes.

- New Maestro helper script `seed_pending_payment.js` mirrors the
  existing seed_history_session pattern.

- New flow `09_chat_tab.yaml`:
    cold-start onboarding → home (returning view) →
    seed completed session + seed pending payment →
    tap "💬 chat" bottom-nav → lands on /chat/aktif via redirect →
    assert "aktif" / "pembayaran" / "selesai" pills + empty-state copy →
    tap pembayaran → assert "menunggu pembayaran sesi" + "bayar Rp..." →
    tap selesai → assert "X menit" duration row → tap row → assert
    "Transkrip Chat" appbar → back → still on /chat/selesai.

  Maestro parsed the YAML cleanly and started executing against the
  device; full run requires backend + online mitra in dev DB (same
  pre-reqs as flows 03/05/06/08).

- TECH_DEBT entry: Stage 10 retired the standalone bestie-history list
  screen, which means (a) the "curhat lagi" targeted-payment entry
  point has no UI affordance anywhere in the app — its plumbing in
  payment_notifier / payment_screen is now orphaned, and (b) the
  Stage 8 flow `08_returning_targeted.yaml` will fail at
  `assertVisible: "Riwayat Chat"` because it expects the deleted
  screen. Three fix paths listed in the entry for product to pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:24:50 +08:00
e3ea1d793e Phase 4 Stage 10 client_app: Chat tab UI (3 sub-tabs + retire bestie_history)
Flutter half of Stage 10 — the new Chat tab landing in the bottom nav.
The CTA target swaps from /chat/history to /chat, which redirects into
/chat/aktif. Three sibling routes under a single ShellRoute share a
header + sub-tab pills + the existing HaloTabBar footer:

  /chat/aktif        — the current active session (0 or 1 row)
  /chat/pembayaran   — pending initial + extension payments
  /chat/selesai      — past sessions, cursor-paginated infinite scroll

URL is the source of truth for the active sub-tab so deep links, back
stack, and Maestro all agree on state.

New feature dir `lib/features/chat_tab/`:
- providers/pending_payments_provider.dart — FutureProvider against the
  Stage-10 backend endpoint, plus pendingPaymentsCountProvider for the
  red-dot derivative
- providers/selesai_history_provider.dart — AsyncNotifier over
  GET /api/client/chat/history; tracks accumulated items + next_cursor +
  hasMore; loadMore() and refresh()
- widgets/chat_row.dart — generic row used by all 3 sub-tabs, with
  optional PaymentAmountChip / DurationChip / 📞 Call indicator
- widgets/sub_tab_pill.dart — pill with active underline + optional
  numeric badge (null hides; matches Selesai's no-badge rule)
- screens/chat_tab_shell.dart — ShellRoute scaffold + ChatSubTab enum
- screens/{aktif,pembayaran,selesai}_view.dart — the three sub-tab bodies

Router (`router.dart`):
- /chat → redirect → /chat/aktif
- ShellRoute hosts /chat/aktif, /chat/pembayaran, /chat/selesai
- /chat/history retired; /chat/history/:sessionId → /chat/transcript/:sessionId
- ChatHistoryScreen import + file deleted

HaloTabBar (`features/home/widgets/halo_tab_bar.dart` — new in the
working tree from Stage 9 sweep): now a ConsumerWidget. Chat tab goes
to /chat. Red dot renders when pendingPaymentsCountProvider > 0.

Inbound call-site updates:
- bestie_choice_sheet.dart: /chat/history → /chat
- home_screen.dart history-row tap: /chat/history/:id → /chat/transcript/:id

This commit also carries the larger Stage 9 sweep + ESP-removal + USP
gate edits that were already staged in the working tree on
`home_screen.dart` and `router.dart` from the prior session.

flutter analyze: clean except for the pre-existing scaffold
test/widget_test.dart MyApp reference (unrelated, present on master).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:14:22 +08:00
350b92f1f3 Phase 4 Stage 10 backend: Chat-tab feeds (pending payments + cursor history)
Backend half of Stage 10 — the new Chat tab in the customer app that
replaces /chat/history with a 3-sub-tab list (Aktif / Pembayaran /
Selesai).

- New GET /api/client/payment-sessions/pending — returns the customer's
  pending initial + extension payment sessions. Filter is status='pending'
  AND expires_at > NOW(). Mitra info comes from session_extensions →
  chat_sessions for extension rows, payment_sessions.targeted_mitra_id
  for targeted-curhat-lagi initial rows. TTL reuses the existing
  payment_session_timeout_minutes app_config row (default 20m) — no new
  config row needed since payment is still mocked.

- getCustomerHistory migrated from offset (page/limit) to cursor
  pagination. Cursor is base64url(`<endedAtIso>|<id>`) with id-tiebreak
  in ORDER BY so rows with identical timestamps don't duplicate or skip
  across pages. SELECT now JOINs payment_sessions to surface `mode`
  (chat/call) for the Selesai-row voice-call pill.

- requirement/flow_customer.mermaid.md: new §7 Chat Tab subgraph + Figma
  cross-ref entry for SChatList.

- requirement/phase4-customer-flow-plan.md: Stage 10 plan section. Also
  carries forward earlier uncommitted "Post-Stage-8 corrections" notes
  from the Stage 9 sweep (boot path / SHome1st / onboarding fixes).

Tests: +7 for getCustomerPendingPayments (initial null mitra,
targeted-mitra fill, extension-via-session JOIN, mixed-newest-first,
expired excluded, non-pending excluded, customer scoping). +10 for
cursor history (empty, exact-fit, multi-page walk, same-timestamp
tiebreak, limit clamp, customer scoping, CLOSING+COMPLETED only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:04:58 +08:00
770f61074c Phase 4 Stage 9: real-device sweep, 4 flows green + 2 shipping bugs fixed
Stage 9 sweep on Client_Phone AVD + physical mitra phone:
- 01_smoke 
- 02_onboarding_verified 
- 03_onboarding_anon 
- 04_payment_expired 
- 05_searching_timeout: in progress when wrap-up began
- 06–08: not yet attempted

## Real shipping bugs fixed (would have hit prod)

1. **Router carve-out too narrow** (router.dart). The AuthAnonymousData
   carve-out only protected /auth/display-name. On refreshListenable
   notify after loginAnonymous resolves, GoRouter re-evaluates the
   *bottom* of the navigation stack (/welcome — also an auth route),
   and the AuthAnonymousData fallback redirected to /home, tearing down
   the verif sheet before it could open. Loosened to allow any auth
   route under AuthAnonymousData.

2. **Phase 4 multi-screen payment never called startSearch**
   (searching_screen.dart). The legacy single-screen /payment did
   `pairing.startSearch()` on confirm. The Phase 4 flow is
   waiting → notif-gate → /chat/searching with no intermediate that
   owned the call — customers would land on the searching screen with
   no pairing in flight and never get matched. Added the kickoff to
   searching_screen::initState when state is PairingInitialData and
   paymentDraft.paymentId is set.

## Test infrastructure

- Self-contained Maestro flows 04 + 05 with inline verified-onboarding
  prelude, distinct test phones per flow, robust waits.
- 02 + 03 fixed: malformed `extendedWaitUntil` (visible: + notVisible:
  true → Maestro parsed as compound predicate); now use proper
  notVisible: block.
- New dev-only POST /internal/_test/force-confirm-payment so flows can
  advance past the waiting-payment screen without going through Xendit.
- /internal/_test/reset-phone now cascades through chat_messages →
  chat_sessions → payment_sessions → auth_sessions before deleting the
  customer row (FK 23503 was blocking re-runs).
- /internal/_test/force-pairing-timeout now accepts both
  `searching` and `pending_acceptance` states (mitra-online dev means
  the chat_session transitions through searching very quickly).
- mark_latest_payment_paid.js helper script for Stage 5+ flows.

## Maestro YAML quirks documented in flows

- text: matches anchored regex against the FULL content-desc — need .*
  wildcards for substring, e.g. "mulai.*Rp.*" not "mulai".
- The middot `·` and other special unicode break naive matching;
  always use .* anchors when the source string contains them.
- runFlow `when:` evaluates immediately; pair with waitForAnimationToEnd
  or a preceding extendedWaitUntil before branching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:11:05 +08:00
ccc52a5c3c Phase 4 plan: status header — stages 0-8 code-complete on master
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:49:48 +08:00
862fc35a40 Phase 4 Stage 8: returning-user shell + Tanya Admin sheet
Bestie Choice Sheet on home Mulai Curhat CTA. When the user has at
least one prior session (bestieHistoryHasItemsProvider hits the chat-
sessions history endpoint), the CTA opens a HaloBottomSheet with two
cards: 'bestie yang udah kenal' -> /chat/history, 'bestie baru' ->
/payment/entry. Empty history -> direct to /payment/entry.

Bestie history list visual upgrade: HaloOrb (mitraId seed) + name +
last-session date + topic pills + sessions count + ONLINE pill.
Backend getCustomerHistory now returns topics, mitra_is_online,
sessions_count in a single payload (no per-row presence round-trip).

BestieOfflinePopup with two variants (returning | new_) replacing the
legacy BestieUnavailableDialog. tanya admin ghost CTA on both variants
opens the new TanyaAdminSheet. Stage 5's targeted-wait declined stub
+ Stage 7's chat-screen 409 stub + searching-screen call site all
migrated to the real component.

TanyaAdminSheet: HaloBottomSheet with WA + Telegram buttons, deeplinks
fetched via supportHandlesProvider (CC-config-driven). url_launcher
added to client_app; ios LSApplicationQueriesSchemes covers
https/http/whatsapp/tg.

Stage 2's OTP-blocked popup hubungi admin SnackBar stub also migrated
to TanyaAdminSheet.

Dev-only POST /internal/_test/seed-history-session lets Maestro 08
flow seed a history row before exercising the choice sheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:47:02 +08:00
d454fd39db Phase 4 Stage 7: end-of-session 2-step confirm + thank-you screen
Customer-driven session end flow:
- AppBar 'akhiri' action on chat_screen (visible when connected and
  not already closing).
- Tap fires confirm_end_step1 HaloPopup. lanjut akhiri -> step2;
  gak jadi balik -> dismiss, stay in chat.
- confirm_end_step2 HaloPopup. tulis pesan penutup -> closing_message_sheet
  HaloBottomSheet (textarea + kirim & akhiri / lewat — langsung akhiri).
  lewati saja closes immediately.
- Both close paths POST /api/client/session/:sessionId/end via
  session_closure_notifier.closeSession() and route to /chat/thank-you.
- 409 from the close endpoint surfaces a ClosureRejectedByMitraData
  state and a stub HaloPopup with TODO(stage8) for the BestieOfflinePopup
  returning variant.

Removed the legacy _showSessionExpiredDialog modal — Stage 6's
ChatExpiredBanner is the replacement notification.

Inline _buildGoodbyeView retained with a TODO for the mitra-side early
end flow (still reaches it).

endSessionTwoStepConfirmProvider hardcoded to true with a TODO — the
Stage 1.5 app_config row exists but no client-readable config endpoint
exists yet. Flip the provider to a FutureProvider once the read endpoint
ships.

Maestro 07_end_session_2step.yaml chains after the chat-happy flow
and asserts the Indonesian copy at each step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:33:01 +08:00
14b5cc966b Phase 4 Stage 6: chat-room countdown UX + voice-call mode pill
Customer chat screen:
- Voice-call header pill (mode == 'call' renders accent-colored pill;
  chat mode renders no pill).
- HaloSnackbar fires once per session at 180s remaining ('sisa 3 menit
  lagi ya 🤍'), driven by the backend session_warning WS event.
- Last-2-min danger styling: timer pill flips to HaloTokens.danger +
  bold JetBrainsMono when remaining <= 120s.
- Floating ChatExpiredBanner widget injected above the input bar when
  remaining hits 0 in closing-grace state. perpanjang -> existing
  pricing bottom sheet.
- pricing_bottom_sheet.dart rewritten to the 5-option layout with
  chat|call mode toggle (mirrors duration-pick from Stage 3).

Mitra chat screen: voice-call header pill only (no countdown UX per PRD).

Backend:
- session.service.js getSessionById JOINs payment_sessions so mode +
  expires_at ship in /api/shared/chat/:id/info.
- session-timer.service.js onThreeMinuteWarning now emits expires_at +
  remaining_seconds for client resync.
- Dev-only POST /internal/_test/force-session-expires-at clears the
  3-min flag, reschedules the timer, and broadcasts WS resync. Lets
  the Maestro flow drive 175s -> 90s -> 0s without waiting live.

New chatRemainingSeconds StreamProvider derived from expiresAt, fed by
session_warning / session_timer / session_expired resync messages
(plan referenced a secondsLeftProvider that didn't actually exist).

Maestro 06_chat_countdown.yaml + force_session_expires_at.js helper.

Out of scope: meet.google.com URL launching - url_launcher isn't a
client_app dependency and message bubbles render plain Text. Defer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:25:11 +08:00
f170d54535 Phase 4 Stage 5: pairing UX upgrades (searching + match + targeted-wait)
Searching screen: soft-prompt card reskin, pulsing-dots panel replaces
the spinner, inline 5-min timeout panel with `coba cari lagi` (resets
pairing notifier + routes to /payment/entry for a fresh funnel — the
server-side payment is failed_pairing at that point so a stale retry
isn't valid) and `kembali ke home` ghost CTA.

Bestie-found screen: S9 Match-V4 reskin — HaloOrb + status dot +
'halo, aku bestie {name}' + `mulai sesi {N} menit →` with N pulled from
the active session's duration_minutes.

Targeted-wait overlay (new) at /chat/waiting-targeted/:mitraId. Three
sub-states from pairingProvider's PairingTargetedWaitingData:
waiting (20s countdown) / accepted (routes to chat) / declined (stubbed
BestieOfflinePopup with a TODO pointing to Stage 8). Reached via
payment_screen._routeToSearchOnConfirmed when the confirm carried a
targetedMitraId — keeps the mandatory payment-before-pairing invariant.

Dev-only POST /internal/_test/force-pairing-timeout drives the 5-min
timeout shortcut for the Maestro flow without waiting live.

Maestro 05_searching_timeout.yaml + force_pairing_timeout.js helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:49:07 +08:00
7ae8f33b2c Phase 4 Stage 4: notif gate + home permission-denied banner
Notif Gate full screen at /onboarding/notif-gate, reached from waiting
payment on confirmed/consumed status. Auto-advances to /chat/searching
when permission is already granted; otherwise shows izinkan/nanti aja
HaloButton CTAs. NotifPermission helper wraps firebase_messaging +
permission_handler with readStatus/request/openAppSettings; cached in
notifPermissionStatusProvider that re-reads on app foreground via an
internal WidgetsBindingObserver.

home_screen amber banner above-the-fold when notifPermissionStatusProvider
reports denied. Dismissable for the session via homeNotifBannerDismissedProvider
(in-memory StateProvider, no persistence - cold-restart re-shows).
nyalain CTA -> openAppSettings().

Manifest + Info.plist permission entries added.

Note: main.dart still pre-requests FirebaseMessaging permission at boot,
which can pre-resolve status so the gate auto-advances instead of acting
as the first prompt. Left intact for now; can be removed in a later
stage if the gate should be the first-ask UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:36:46 +08:00
706149c75e Phase 4 Stage 3: payment shell (multi-screen flow)
Six new screens under /payment/* + a paymentDraftProvider holding
mode/durationId/durationMinutes/priceIDR/paymentId/isFirstSessionDiscount
across the flow. PaymentEntryScreen handles the routing decision
(eligible+enabled -> /payment/discount-paywall, else /payment/method-pick)
and clears the draft on fresh entry.

Screens:
- discount_paywall_screen: S6 first-session discount with struck-through
  gimmick price + actual price + 'mulai · Rp{actual}' CTA -> /payment/method
- method_pick_screen: chat vs call cards
- duration_pick_screen: tier list with chat|call mode toggle that resets
  the selection on swap
- payment_method_screen: QRIS-first list, posts to existing
  /api/client/payment-sessions with mode/duration/price/discount/method
- waiting_payment_screen: qr_flutter QR (encodes paymentId in mock mode),
  20-min countdown header, 3s polling for status, pauses on background
  via WidgetsBindingObserver
- payment_expired_screen: retry CTA -> /payment/method with draft retained

Status mapping: real payment_sessions.status uses 'confirmed'/'consumed'
for paid (not 'paid' as in plan) and 'expired'/'abandoned' as terminal.

home_screen 'Mulai Curhat' CTA now pushes /payment/entry.

Dev-only /internal/_test/force-expire-payment endpoint to drive Maestro
flow 04_payment_expired.yaml without waiting 20 minutes. Gated behind
NODE_ENV !== 'production'.

chat_opening_provider PricingData extended to carry Phase 4 chat/call
groups + firstSessionDiscount, back-compat with the Phase 3 shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:28:59 +08:00
2645bcd0e5 Phase 4 Stage 2: onboarding redesign (client_app + mitra_app)
Verif Choice Sheet on display_name_screen drives the user into either
the verified or anonymous onboarding sub-flow. ESP screen (12 chips,
multi-select, info-only) + USP screen are shared between both branches;
selections persist through to chat_sessions.topics on session start.

OTP-blocked popup (HaloPopup) listens for the four real OTP-rate-limit
error codes (OTP_RATE_LIMIT_PHONE, OTP_RATE_LIMIT_IP, OTP_COOLDOWN,
OTP_ATTEMPTS_EXCEEDED) and drops the user onto the anonymous path with
ESP/USP state preserved.

Auth-providers gating replaces the --dart-define=ENABLE_SOCIAL_AUTH
build flag with server-driven discovery. authProvidersProvider preloads
GET /api/shared/auth-providers at cold start; welcome/register/
force-register screens render Google/Apple buttons only when the
backend reports enabled:true. Falls back to phone-OTP-only when both
providers are off. social_auth_enabled.dart deleted; client_app/CLAUDE.md
updated to reflect the new gating contract.

Mitra app: chat screen renders an ESP chip strip above the first message
bubble when chat_sessions.topics is non-empty.

Backend session.service.js getSessionById SELECTs cs.topics so the mitra
side can read the customer's selected topics.

Maestro flows 02_onboarding_verified.yaml + 03_onboarding_anon.yaml.

Deviation from plan: plan referenced OTP error code 'otp_retry_exhausted';
real codes are OTP_RATE_LIMIT_*/OTP_COOLDOWN/OTP_ATTEMPTS_EXCEEDED -
popup listens for all four. Plan said 'has_paid_first_session'; live
endpoint returns 'has_consulted_before' - used the live field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:23:57 +08:00
4680c36e34 OTP test infrastructure for Maestro flows
Dev-only /internal/_test/peek-otp + /internal/_test/reset-phone endpoints
gated by NODE_ENV !== 'production'. peek-otp reads the latest stub OTP
out of an in-memory map populated by otp.service.js fazpassSendStub;
reset-phone wipes otp_requests rows (and optionally the customers row)
so flows can re-run without tripping cooldowns.

JS + shell helpers under .maestro/scripts/ wrap the endpoints for use
inside Maestro runScript steps. 01_smoke.yaml expanded from a launch-only
sanity check to a full cold-start onboarding -> force-register -> OTP ->
home walk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:19:22 +08:00
d33d4419ea Phase 4 Stage 1: backend foundation (additive endpoints + schema)
Schema (idempotent migration):
- payment_sessions.is_free_trial -> is_first_session_discount (data copied)
- payment_sessions.mode TEXT NOT NULL DEFAULT 'chat' CHECK (chat|call)
- chat_sessions.topics TEXT[] for ESP picks (info-only)

New endpoints:
- GET /api/client/onboarding-state (drives verif sheet + S6 paywall gate)
- GET /api/client/chat-pricing (rewrite: chat+call groups + first-session
  discount block, per-customer eligibility)
- GET /api/shared/auth-providers (env-probed; replaces ENABLE_SOCIAL_AUTH
  build flag — frontend cutover lands in stage 2)
- GET /api/client/support-handles (Tanya Admin handles, CC-config-driven)

session_warning WS event fires once at 180s remaining.

app_config seeds (mock pricing tiers, first-session discount, support
handles, payment method order, end-session 2-step toggle).

CC SettingsPage: 3 new sections (first-session discount, pricing tiers
JSON editors, support handles).

15/15 Vitest passing. chat_sessions.is_free_trial also renamed for
consistency (plan only specified payment_sessions; pairing.service.js
read both).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:56:28 +08:00
4ada7c991a Phase 4 Stage 0: design system foundation (client_app)
- HaloTokens, HaloSpacing, HaloRadius, HaloMotion, HaloShadows (warm palette;
  calm/playful stubbed for phase 5).
- Bundled Bricolage Grotesque, Poppins, JetBrains Mono (~1.2 MB total, OFL).
- haloThemeData() wired into MaterialApp.router with Figma-aligned text
  scale, pill ElevatedButton, 64px input height, 24px-corner BottomSheet,
  dark pill SnackBar.
- Halo* widget primitives: Button, Orb, StepDots, BottomSheet, Popup,
  Snackbar, Chip.
- Dev-only /_theme_preview route gated by --dart-define=THEME_PREVIEW=true
  for visual reference during stages 2-8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:56:00 +08:00
8c212cb464 Phase 4 PRD + plan: customer-flow redesign (Figma alignment)
Adds the Phase 4 requirement docs that align the customer app with the new
HaloBestie Figma design dump.

- requirement/flow_customer.md: source-of-truth numbered flow (input)
- requirement/flow_customer.mermaid.md: 6 mermaid diagrams + Figma cross-ref
- requirement/phase4-customer-flow.md: PRD (15 functional sections)
- requirement/phase4-customer-flow-plan.md: 10-stage implementation plan
- .gitignore: exclude requirement/Figma.zip + extracted Figma/ folder

Resolved product decisions: no free trial (replaced by configurable
first-session discount), pricing has independent chat/call groups,
voice-call mode is chat-with-badge (mitra shares Meet link manually),
social login is server-driven via /api/shared/auth-providers, ESP tags
are info-only (not used for matching).

No code changes; implementation starts at plan stage 0 (design system).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:21:26 +08:00
d09e50af55 Phase 3.7: paid pairing flow + returning chat + extension flip
- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:02:49 +08:00
f3766813f3 Add Claude Code subagent definitions
Commit project-scoped subagents (backend, control center, client_app,
mitra_app) so they travel with the repo across machines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:30:41 +08:00
89afd01899 Phase 3.5: Mitra Chat Request History (backend route + mitra app screens)
Replaces the home-screen pending-requests banner with a "Riwayat
Permintaan" CTA that opens a list of the mitra's last 20 chat requests
(any status). Pending rows pin to the top; non-pending rows open a
read-only detail screen with a "Lihat percakapan" CTA on accepted rows.

Backend:
- New service `getRecentRequestsForMitra(mitraId, { limit })` capped at
  20, pending pinned via `(response IS NULL AND status='pending_acceptance')
  DESC`. Customer call_name returned verbatim, with `'Anonim'` only as
  null-safety fallback (no anonymity-flag masking — see project memory).
- New route `GET /api/mitra/chat-requests/recent`. Strictly per-mitra
  scoped via the existing `resolveMitra` preHandler.

Mitra app:
- New `RequestResponse` enum in core/constants.dart.
- New Riverpod notifier `requestHistoryProvider` (AsyncValue<List<...>>,
  keepAlive) — pull-to-refresh + screen-mount fetch only, no WS.
- Two new screens (history list + detail) and two new GoRoutes.
- Home screen: `_PendingRequestsBanner` removed → `_RequestHistoryButton`
  Card with red count badge. Live count comes from the existing
  chatRequestProvider so nothing changes about the WS-driven badge math.

Plan + acceptance criteria in requirement/phase3.5-plan.md. flutter
analyze clean (zero new issues). Backend smoke-tested against real DB.
Real-device E2E pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:59:17 +08:00
e54bdf2c6c Tier 1 hardening: trustProxy + per-IP rate limit + anonymity flag
- Fastify public app now passes `trustProxy: true` so request.ip resolves to
  the real client IP from X-Forwarded-For when behind Cloud Run / a load
  balancer. Without this the per-IP rate limit was either useless or
  collapsed all users into one shared LB IP.
- The `anonymity_enabled` config row + JS default + migration seed now
  default to `false`. The flag is dead code (no business logic ever
  consumed it) and the actual rule is simpler than the toggle implied:
  mitras always see the customer's chosen call_name; only phone+email
  are private. The whole feature is queued for rip-out as a separate
  cleanup pass.

The per-IP OTP rate limit (10/hr) was also effectively disabled by
upserting `app_config.otp_max_per_ip_per_hour = 1000000` — a runtime
config change, not a code change. Per-phone (3/hr) + Fazpass cost
remains the real abuse gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:58:26 +08:00
a560b0936c PRDs: phase3.5 (mitra chat request history) + phase3.6 (force-close re-enable)
Phase 3.5: replace _PendingRequestsBanner with a Riwayat Permintaan card
on the mitra home, plus a screen listing the last 20 entries from
chat_request_notifications. Backend endpoint TBD.

Phase 3.6: plan to re-enable mitra force-close (Akhiri) once the moderation
/ accountability story is in place. Backend route and config flag are
already preserved from Phase 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:09:19 +08:00
d9869bf6af Phase 3.4: customer OTP screen rewrite + lockout UX + bug fixes
OTP screen rewrite: 6 rounded boxes, auto-advance focus, auto-submit on the
6th digit, hardware-backspace on empty boxes (intercepted via Focus.onKeyEvent
since TextField.onChanged doesn't fire on already-empty input), resend
cooldown sourced from /api/shared/config/otp, and an inline error message
under the boxes instead of a SnackBar.

Several bugs fixed inline that surfaced during testing:

- ref.listen inside build() accumulates listeners on every rebuild — the
  resend countdown's per-second setState was piling up duplicate listeners
  so one error triggered N callback fires. Moved to ref.listenManual in
  initState; subscription disposed in dispose().

- RouterNotifier was calling notifyListeners() on every auth state change
  including AsyncError, which rebuilt the Navigator/Scaffold mid-snackbar
  and visually duplicated the error toast. Now skips AsyncError and
  same-data-variant transitions.

- ScaffoldMessenger.showSnackBar from a Riverpod listener callback could
  still render twice even with hideCurrentSnackBar — replaced with an
  inline error widget to sidestep the snackbar machinery entirely.

- register_screen now uses context.go instead of context.push for the
  OTP route, so re-submitting the phone form doesn't stack multiple
  OtpScreen instances with active subscriptions.

Lockout UX: AuthErrorInfo wraps the error message + code + retry_after_seconds
parsed from the backend's structured error response. On rate-limit codes
(OTP_COOLDOWN, OTP_RATE_LIMIT_PHONE, OTP_RATE_LIMIT_IP), the OTP screen
extends "Kirim ulang kode" cooldown to match the server's wait, and the
register screen disables "Kirim OTP" with a "Coba lagi dalam …" countdown.
formatCountdown() in core/constants.dart renders Xd under 90 seconds and
Xm Yd above (clearer than raw seconds for long lockouts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:54:49 +08:00
3a7378d246 Phase 3: hide mitra force-close (Akhiri) button in active sessions
Removes the Akhiri button + confirmation dialog + _endSession from the
mitra active sessions screen. Backend POST .../end-early route and the
early_end_mitra_enabled config flag are preserved — re-enable plan lives
in requirement/phase3.6.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:44:12 +08:00
fa7071def5 Phase 3.4: structured rate-limit retry-after + auth error logging
OtpError now carries an optional details object; rate-limit branches in
checkRateLimits compute retry_after_seconds (cooldown delta for OTP_COOLDOWN,
window-roll-out delta for OTP_RATE_LIMIT_PHONE / OTP_RATE_LIMIT_IP) so the
client can disable Kirim OTP / Kirim ulang CTAs with a real countdown.

All four sendAuthError helpers (client, mitra, shared, internal) now surface
err.details and log unhandled (no statusCode) errors at level 50.

New GET /api/shared/config/otp returns the resend cooldown so the OTP screen
can gate the resend countdown without hardcoding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:43:56 +08:00
6de541848c Phase 3.4: customers.display_name nullable + identity-only social scope
Drop NOT NULL on customers.display_name so phone-OTP and social signups can
land before the user picks a name; frontend then routes them to /auth/set-name.
Google sign-in no longer requests the name claim and Apple SDK scope is
trimmed to email only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:43:37 +08:00
6801001b64 Phase 3: closing-overlay fix + goodbye-composer dedupe
Customer chat refreshSessionStatus now clears sessionExpired carryover so the
goodbye composer renders correctly when re-opening a closing session from
history. Backend /api/shared/chat/:id/info returns goodbye_submitted_by_me;
both apps suppress the composer for the side that has already submitted and
render an awaiting-banner view instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:43:19 +08:00
05ab1e10df Pin minSdk = 24 explicitly on both apps
Both apps were inheriting `flutter.minSdkVersion`, which currently resolves
to 24 (Android 7.0) in this Flutter SDK but could drift if Flutter bumps
its default. Per product requirement we support Android 7+; making the
floor explicit so it doesn't move silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:47:35 +08:00
f8380163bc Phase 3: session-end UX overhaul + closing-grace cleanup
Promotes the customer-side chat WebSocket to active-session-scoped (driven
by a new `activeSessionProvider`) so home reflects session state in real
time without a per-screen connection. Backend now auto-completes sessions
left in `closing` after a 5-minute grace window so abandoned goodbye flows
don't leave the customer's home permanently locked.

Customer:
- New `activeSessionProvider` (replaces `unread_notifier`) — single source
  of truth for the active session + unread count; polled every 15s.
- Chat WS lifecycle moved to `main.dart` listener on activeSessionProvider.
  Chat screen joins via `connectIfNotConnected`; the new
  `refreshSessionStatus` reconciles flags from the server when re-entering
  an already-connected session (covers missed `sessionClosing`/`sessionExpired`
  WS events).
- Home filters `closing` from the "Sesi Aktif" CTA so a session pending
  goodbye doesn't block "Mulai Curhat".
- Timer-expired UX is a non-dismissible modal (Tutup / Perpanjang) instead
  of an inline bar.
- Early-end goodbye composer gets an amber "Sesi telah ditutup oleh Bestie"
  banner. Goodbye TextEditingController lifted to state so focus changes
  no longer wipe the message.
- Closure provider reset on chat_screen mount to avoid stale
  `ClosureCompleteData` from a previous session leaking into a new view.
- Chat history now lists `closing` sessions with a "Belum ditutup" badge
  that routes to the live chat (goodbye composer) instead of the transcript.

Mitra:
- Same goodbye-controller fix as customer.
- Same chat-history badge + routing for `closing` items.

Backend:
- New `EndedBy.SYSTEM_AUTO_CLOSE` constant.
- `startClosureGraceTimer` extracted in `session-timer.service.js`; wired
  in from `closure.initiateEarlyEnd`, `extension.rejectExtension`, and
  `extension.handleExtensionTimeout`. Cancelled when customer submits
  goodbye.
- Restart recovery (`restoreActiveTimers`) re-arms grace timers and stamps
  any orphaned `closing` rows with `system_auto_close`.
- `getCustomerHistory` / `getMitraHistory` include `closing` alongside
  `completed`; ordering uses `COALESCE(ended_at, created_at)`.

Removed: dead `session_active_screen.dart` (no router entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:47:24 +08:00
b59c66f7df Consolidate testing checklist into phase3.4-testing.md
Replaces phase3.3-testing.md. New doc covers:
- Part 1: Phase 3.4 self-managed auth — backend curl matrix, CC UI
  (cookie refresh + bridge), mitra_app + client_app (anonymous →
  upgrade, OTP stub codes, social behind flag), cross-app WS handshake
- Parts 2-4: Phase 3.3 topic sensitivity + 3.2 overlay/E2E/iOS + 3/3.1
  session lifecycle / chat mechanics / navigation — verbatim carry-over
- Part 5: Cross-cutting regression after 3.4 merge, platform coverage,
  security/negative (JWT leak, refresh rotation, cookie flags), and
  Known Blockers / Deferred updated for 3.4 reality (Valkey revocation,
  merge-on-link, firebase_uid drop, real Fazpass, social creds, Apple
  Dev prereqs, JWT rotation procedure)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:14:46 +08:00
98156d1e49 Phase 3.4: client_app self-managed auth cutover
Rips firebase_auth; auth talks directly to the new backend endpoints.
Anonymous-first + phone OTP work end-to-end; Google/Apple SDKs are kept
but buttons are hidden behind ENABLE_SOCIAL_AUTH until backend OAuth
credentials are provisioned.

Smoke-tested against the backend via curl:
- anonymous → PATCH display_name → /me
- OTP request (read stub code from backend log) → verify with
  anonymous_customer_id → same customer row preserved, display_name
  preserved, phone added → upgrade confirmed
- refresh rotation + logout → post-logout refresh correctly fails
  REFRESH_INVALID
- Debug APK builds clean

- pubspec: drop firebase_auth; add flutter_secure_storage
- core/auth/auth_bridge.dart: shared mutable state (access token +
  refresh callback + in-flight de-dup) — keepAlive provider
- core/auth/token_storage.dart: flutter_secure_storage wrapper
  (customer_refresh_token key)
- core/auth/social_auth_enabled.dart: const flag from
  --dart-define=ENABLE_SOCIAL_AUTH (default false)
- core/auth/auth_notifier.dart: bootstrap via stored refresh; anonymous
  via /api/shared/auth/anonymous + PATCH display_name; phone OTP via
  /api/client/auth/*; Google + Apple wired (passes anonymous_customer_id
  for upgrade); anonymity config check for ForceRegister state; granular
  error-code mapping
- core/api/api_client.dart: Bearer from bridge + postRaw(skipAuth) for
  auth endpoints + single-retry 401 refresh
- core/chat/chat_notifier.dart + core/pairing/pairing_notifier.dart: WS
  auth frame reads bridge.accessToken
- features/auth/screens/otp_screen.dart: verificationId → otpRequestId
- features/auth/screens/register_screen.dart + force_register_screen.dart:
  Google/Apple buttons gated behind kSocialAuthEnabled; force_register
  drops obsolete linkAccount() (upgrade happens server-side now via
  anonymous_customer_id)
- client_app/CLAUDE.md: Auth section rewritten (was stale on Firebase)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:08:20 +08:00
2b61c79a86 Phase 3.4: mitra_app self-managed auth cutover
Rips firebase_auth; phone OTP flow now talks directly to the new
backend endpoints, JWT access token lives in memory, refresh token
persists via flutter_secure_storage. WebSocket handshakes read the
access token from AuthBridge instead of Firebase.

Smoke-tested end-to-end against the backend via curl:
- otp/request → read stub code from backend log → otp/verify
- /api/mitra/auth/me + /api/shared/auth/refresh rotation
- logout → post-logout refresh correctly fails REFRESH_INVALID
- ACCOUNT_INACTIVE (403) + WRONG_FLOW (400) error paths verified
- Debug APK links cleanly

- pubspec: drop firebase_auth, add flutter_secure_storage
- core/auth/auth_bridge.dart: shared mutable state (access token +
  refresh callback + in-flight de-dup) as keepAlive provider
- core/auth/token_storage.dart: flutter_secure_storage wrapper
- core/auth/auth_notifier.dart: bootstrap → refresh; requestOtp +
  verifyOtp via /api/mitra/auth/*; logout; granular OTP error codes
- core/api/api_client.dart: Bearer from bridge + postRaw(skipAuth) for
  auth endpoints + single-retry 401 refresh
- core/chat/*_notifier.dart: WS auth frame reads bridge.accessToken
- features/auth/screens/otp_screen.dart: verificationId → otpRequestId
- mitra_app/CLAUDE.md: Auth section rewritten (was stale on Firebase)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:58:25 +08:00
4a796277b8 Phase 3.4: control_center self-managed auth cutover
Replaces Firebase Auth with the new JWT + httpOnly-cookie refresh flow.
Smoke-tested end-to-end via curl (login → /me → refresh rotation → logout).

- Remove firebase dep + firebase.js
- New token-bridge decouples api-client from AuthContext and de-dupes
  concurrent 401 refreshes
- AuthContext: in-memory access token (useRef), bootstrap via
  /internal/auth/refresh, login/logout/refresh methods
- api-client: withCredentials, Bearer attach, auto-retry once on 401
- LoginPage: handle INVALID_CREDENTIALS / ACCOUNT_LOCKED / VALIDATION_ERROR
- Layout: self-service "Ganti password" form
- UsersPage: initial password field on create + per-row admin-forced reset
- .env / .env.example: drop VITE_FIREBASE_* vars
- backend/CLAUDE.md + control_center/CLAUDE.md: describe new auth (were
  stale on Firebase)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:32:32 +08:00
1a610363bb iOS navigation fixes: deep-link pop fallback + back-button PopScope
- notification_service: use GoRouter.go (not push) for terminal states
  (session_closing, session_expired) so the nav stack doesn't linger
  behind deep-linked screens
- chat_screen: PopScope + canPop fallback in client_app so iOS back
  gestures fall back to /home when there is nothing to pop
2026-04-24 11:58:05 +08:00
3a25ddc41d docs: mark Phase 3.4 backend done in plan doc
Added progress snapshot at the top of phase3.4-plan.md noting:
- Backend cutover complete (commit f860ab6)
- Fazpass stubbed until real API docs arrive
- Frontend rewrites (client_app, mitra_app, control_center) pending
- Apple Developer prereqs still required
- Consolidated phase3.4-testing.md still to be written

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:51:19 +08:00
f860ab6c85 Phase 3.4: backend self-managed auth cutover
All backend auth now goes through our own token service — Firebase Auth
dependency is fully removed from auth paths. FCM (firebase-admin messaging)
is still used for push.

Schema:
- auth_sessions (multi-device refresh tokens, bcrypt-hashed)
- otp_requests (Fazpass reference + rate-limit history)
- customers.email + google_sub + apple_sub (social identity)
- control_center_users.password_hash + failed_login_count + lockout_until
- firebase_uid columns made nullable (drop in later cleanup migration)
- 6 new app_config keys for OTP + CC lockout tuning

Services:
- password.service.js — bcrypt cost 12 + complexity (min 8, digit + upper +
  lower)
- token.service.js — JWT HS256 access (1h) + opaque refresh (30d, bcrypt-
  hashed, rotated on use); session_id claim pre-wires future Valkey-based
  instant revocation; revokeSession + revokeAllSessionsForUser helpers
- social-identity.service.js — Google via google-auth-library, Apple via
  jwks-rsa + jsonwebtoken
- otp.service.js — Fazpass stub (generates locally, logs the code) clearly
  marked for replacement once real API docs arrive; rate-limit + resend
  cooldown + verify-attempts all configurable via app_config
- auth.service.js — orchestrator: signInAnonymous, completeCustomer/Mitra-
  PhoneSignIn, signInWithGoogle, signInWithApple, signInCcUser, refresh,
  logout; reject-on-existing for identity conflicts
- cc-user.service.js — email+password helpers + lockout counters

Routes & middleware:
- authenticate middleware now verifies our JWT and attaches
  request.auth = { userType, userId, sessionId }
- WebSocket handshake verifies our JWT (no more Firebase lookup)
- All existing routes updated to use request.auth.userId instead of
  request.firebaseUser.uid
- New public routes:
    /api/shared/auth/anonymous /refresh /logout
    /api/client/auth/otp/request /otp/verify /google /apple /me /profile
    /api/mitra/auth/otp/request /otp/verify /me
- New internal routes:
    /internal/auth/login /refresh /logout /me (httpOnly cookie refresh)
    /internal/control-center-users (accepts plain password, bcrypt-hashed)
    /internal/control-center-users/me/password (self-service change)
    /internal/control-center-users/:id/password (admin forced reset)
- Deleted legacy customer.routes.js (anonymous + link handled by auth now)
- app.internal.js: @fastify/cookie + CORS credentials for CC httpOnly cookie

Config:
- AUTH_JWT_SECRET + ACCESS_TOKEN_TTL_SECONDS + REFRESH_TOKEN_TTL_DAYS env
- FAZPASS_* env vars (TBD until real API docs)
- GOOGLE_OAUTH_CLIENT_IDS, APPLE_SERVICES_ID/TEAM_ID/KEY_ID/PRIVATE_KEY
- ADMIN_EMAIL + ADMIN_PASSWORD for seed
- CC_ORIGIN for internal-app CORS origin allowlist

Dependencies:
- Added: bcrypt, jsonwebtoken, jwks-rsa, google-auth-library, @fastify/cookie
- Kept: firebase-admin (messaging only)

Still outstanding: Fazpass API integration (stub in place), Apple Developer
prereqs for end-to-end iOS testing, client_app/mitra_app/control_center auth
flow rewrites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:43:25 +08:00