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>
§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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
- Mitra auth: parse DioException response for proper error messages
(ACCOUNT_NOT_FOUND, ACCOUNT_INACTIVE) instead of generic "OTP invalid"
- Backend: add CORS to internal app (port 3001) for control center
- Control center: fix login race condition (wait for AuthContext verify
before navigating), fix MitraActivityPage fetching paginated data
- Stale session goodbye: both apps detect SESSION_NOT_ACTIVE/409 and
move to complete state instead of retrying endlessly
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Backend: new GET /api/mitra/chat-requests/pending endpoint
- Backend: getPendingRequestsForMitra() queries unresponded notifications
for sessions still in pending_acceptance status
- Mitra app: loadPendingRequests() fetches on screen load + status toggle
- Mitra app: activeRequestCount getter exposes queue size
- Mitra app: _PendingRequestsBanner widget shows count with tap-to-view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- DB migration: add active_session_count column + mitra_notified index
- Constants: add MISSED to NotificationResponse
- Pairing service: record active_session_count on notification creation,
use MISSED (not IGNORED) when another mitra accepts first
- New mitra-activity.service.js: getMitraActivityLog (paginated),
getMitraActivitySummary (per-mitra aggregates with acceptance rate)
- New mitra-activity.routes.js: GET /internal/mitra-activity/log,
GET /internal/mitra-activity/summary
- Control center: new MitraActivityPage with summary table + detail log,
filters (mitra, date range), color-coded response types, pagination
- Register route in App.jsx, add "Aktivitas Mitra" nav link in Layout
Co-Authored-By: Claude Opus 4.6 (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>
- Add require_mitra_ping + mitra_ping_interval_seconds config keys (migration)
- Add getMitraPingConfig/setMitraPingConfig to config service
- Add GET/PATCH /internal/config/mitra-ping routes for control center
- Update mitra status service: honor ping config in auto-offline sweep,
include ping config in GET /api/mitra/status response
- Enhance pairing FCM payload with action: 'open_accept' for deep-link
- Add FCM fallback to closure.service (initiateEarlyEnd, completeSession)
- Add FCM fallback to session-timer.service (onSessionExpired)
- Add unread count queries (getActiveSessionByCustomerWithUnread,
getActiveSessionsByMitraWithUnread)
- Add GET /api/client/chat/session/active-with-unread route
- Add GET /api/mitra/chat-requests/sessions/active-with-unread route
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>
Add mitra online/offline status with heartbeat-based auto-offline,
customer-mitra pairing via Valkey pub/sub blast, session management,
and control center dashboard with real-time stats.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>