Customer end-of-session (figma §6):
- PricingBottomSheet: ghost "cukup, akhiri sesi" CTA + dedup divider
- chat_screen._runEndSessionFlow chains ConfirmEndStep1 → ConfirmEndStep2
→ ClosingMessageSheet (or "lewati saja" → close + /home). The four
popup/sheet widgets already existed; this commit just wires them
- showModalBottomSheet: showDragHandle=false to suppress the Material 3
auto-injected handle that was stacking with our own pill
Notification sound on API 33+:
- Bump channel halobestie_chat_v1 → halobestie_chat_v2, created from
native Kotlin in MainActivity.kt with AudioAttributes contentType
CONTENT_TYPE_SONIFICATION. flutter_local_notifications' default of
CONTENT_TYPE_UNKNOWN was causing Android 13 to silently drop audio
focus while the notification still posted (isNoisy=true). Both apps
- Backend FCM payload channelId updated to v2
- AndroidManifest meta-data: default_notification_icon + color → brand
silhouette tinted pink instead of generic Android bell. Both apps
Customer pairing reliability:
- pairing_notifier: applyPairedFromPush({sessionId, mitraName}) unsticks
searching screen when WS push failed and FCM/active-session-poll is
the first signal. Idempotent across PairingSearchingData,
PairingTargetedWaitingData, PairingErrorData (covers ALREADY_ACTIVE)
- notification_service: dispatches every FCM data payload to an
onDataMessage callback (foreground + tap + cold-start). main.dart
wires that to applyPairedFromPush on type=='paired'. Foreground
'paired' no longer renders a local banner — screen self-advances
- main.dart activeSession listener also calls applyPairedFromPush when
a session appears server-side while pairing is in a waiting state.
Covers stale ALREADY_ACTIVE recovery without a full page refresh
Auth refresh token race:
- auth_notifier._refreshFromStorage shares a single in-flight Future
across all callers (Auth.build + 401-retry path). Backend rotates
refresh tokens, so concurrent callers using the same stored token
would race → loser 401s → catch wipes flutter_secure_storage → user
appears logged out after kill+reopen
Polish:
- method_pick_screen: resizeToAvoidBottomInset=false — prevents the
one-frame overflow when entering with the previous screen's keyboard
still animating out
- bestie_history: BestieHistoryItem now carries `status` (backend
already returns it). Removed _rawHistoryProvider that fetched the
same endpoint just to read status; the two providers could go out
of sync mid-rebuild and throw RangeError(length) on indexing
Xendit Stage 8 (carried from WIP):
- xendit_checkout_screen: embedded webview hosting Xendit's invoice
page (intercepts halobestie:// deeplink + return-page URLs for
deterministic pop)
- waiting_payment_screen: auto-pushes the webview when the backend
payload includes xendit_invoice_url; spinner card + "Buka ulang
halaman pembayaran" CTA for the QR-fallback path
- pubspec: webview_flutter ^4.13.0
Maestro infra:
- subflows/onboarding_returning_user: drop the "Mulai" carousel wait
(splash auto-advances since 2026-05-26); tap phone-field hint
instead of point; drop hideKeyboard (sends BACK → /home when the
IME isn't actually up)
- New flow ts-customer-06-01-end_session_via_timeup_sheet: drives
the full path to the chat-expired banner. Last step blocked by a
Maestro+Flutter gesture quirk on the perpanjang ElevatedButton
(raw `adb input tap` works at the same coords). Documented in
memory; deeplink fixture or manual verify recommended
- ChatExpiredBanner button wrapped with Semantics(identifier:
'chat_extend_button', button: true, onTap: …) — good hygiene for
future tests even though it doesn't fix the dadb tap issue
.dev/: tracked wsl_emulator_bridge.ps1 + wsl_tcp_relay.py for
Maestro-on-WSL setup (Windows-side netsh portproxy + WSL-side
loopback relays). Both referenced from existing CLAUDE.md notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
- Backend: getOrCreateCustomer with phone fallback for re-login
- Backend: PATCH /api/client/auth/profile for display name update
- Client app: AuthNeedsDisplayNameData state + SetDisplayNameScreen
- Client app: ApiClient.patch method
- Both apps: handle verificationCompleted for auto-verify (test numbers)
- Both apps: skip credential sign-in if already auto-verified
- Remove debug prints from mitra auth + OTP screens
- Fix ChatRequestNotifier.startListening skips when accepting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove flutter_bloc and equatable dependencies from both apps
- Delete all 10 old bloc files (5 per app)
- Fix 6 remaining screens that used context.read<ApiClient>() from
flutter_bloc → converted to ConsumerStatefulWidget/ConsumerWidget
with ref.read(apiClientProvider)
- Both apps now use Riverpod exclusively for state management
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add phase3.1 requirement and implementation plan docs
- Add Riverpod dependencies to both client_app and mitra_app
- Wrap both app roots with ProviderScope
- Migrate client_app AuthBloc → AuthNotifier (@riverpod annotation)
- Migrate client_app ChatOpeningBloc → chatPricingProvider (FutureProvider)
- Update router to use Riverpod-based auth state for redirects
- Update all auth screens (display name, register, OTP, force register)
- Update home screen and pricing bottom sheet
- Add android:usesCleartextTraffic for dev HTTP access on both apps
- mitra_app prepared with ProviderScope + ApiClient provider (blocs next)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Upgrade Fastify 4→5 with all plugins (@fastify/websocket 11, cors 11, sensible 6)
- Migrate all SSE endpoints to WebSocket + FCM push (mitra chat requests, customer pairing status)
- Add flutter_local_notifications for foreground push notifications with sound
- Add splash screen to both apps (hide auth loading flash)
- Introduce constants/enums across entire codebase (no raw string literals)
- Move price tiers from hardcoded array to app_config DB (data-driven, includes 1-min test tier)
- Add session ownership validation on all shared chat routes
- Add ownership checks on endSession, respondToExtension, requestExtension
- Fix session timer: auto-complete expired/stale sessions on server restart
- Add 5-min grace period for abandoned closing sessions
- Fix extension flow: proper session_resumed handling, clearExtensionRequest, closure grace timer cleanup
- Fix chat screens: ConnectChat in initState, session status check on connect
- Fix customer expired view: 5-min countdown, closure state priority over expired state
- Fix mitra extension UI: loading spinner, disable buttons, handle EXTENSION_RESOLVED error
- Fix GoRouter navigation consistency (no more Navigator.pushNamed)
- Fix goodbye view keyboard overflow (SingleChildScrollView)
- Add active session card on customer home screen with refresh on navigate back
- Fix PricingBottomSheet extension mode (RequestExtension instead of new pairing)
- Send session_resumed to both parties on extension accept
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Integrated Firebase SDK in both Flutter apps (google-services, firebase_options)
- Fixed auth flow, API client, and pairing/status blocs for dev environment
- Added full Flutter project scaffolds (android, ios, web, etc.)
- Added phase 3 chat engine requirement document
- Added bugreport zip pattern to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>