Backend deploy target is self-hosted Docker (VPS / Kubernetes / Docker
Engine), not Cloud Run. Add a multi-stage Dockerfile (Node 20, bcrypt
compiled in build stage, non-root runtime), .dockerignore, a staging
docker-compose, and DEPLOY.md covering install, build, migrate, run, and
log mapping/rotation. Pin engines.node>=20. Update deployment.md runbook
and backend/CLAUDE.md infra line off Cloud Run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a reusable WebPageScreen (webview_flutter host with close button +
progress bar, no nav interception) and wire the profile 'kebijakan privasi'
menu item to open https://mybestieindonesia.com/privacy in it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GA4 funnel instrumentation + unified home CTA, AGP-8 Firebase Analytics
upload fix, analytics funnel docs, and dev static-OTP mitra helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Android product flavors (.dev/.staging suffixes, prod clean) + per-flavor
Dart entrypoints, dart-define env files, and per-flavor Firebase config for
both platforms across 3 projects (halobestie-clone-dev / my-bestie-876ec /
my-bestie-production).
- Android: flavorDimensions("env") + productFlavors; @string/app_name label;
per-flavor src/<flavor>/google-services.json (clients verified to match each
applicationId).
- iOS: customer app re-based to the EXISTING App Store identity
com.asc.hallobestie (dev/staging suffix it; ships as an update to the live
app). mitra is a new app (com.mybestie.mitra). Per-flavor plists staged in
ios/config/<flavor>/; Xcode scheme wiring deferred (Mac follow-up).
- firebase_options_{dev,staging,prod}.dart filled with real android + iOS
values (regenerated from the native config files).
- BUILD_FLAVORS.md per app documents flavor table, build commands, iOS
identity decision, and the remaining iOS Xcode steps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
setup-test-mitra-otp.mjs adds a phone+mitra-scoped entry to the
app_config.test_otp_bypass allowlist and ensures an ACTIVE mitra row
(createMitra defaults inactive -> 403). Dev/QA convenience; the bypass is
checked before Fazpass in requestOtp so it short-circuits even when
FAZPASS_ENABLED=true. Idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
google-services plugin 4.3.10 is incompatible with AGP 8.x: it generated
google_app_id into a values.xml but never merged it into the APK, so native
Firebase reported "Missing google_app_id. Firebase Analytics disabled" and
uploaded nothing (FCM still worked via the Dart-side init, masking it).
- bump com.google.gms.google-services 4.3.10 -> 4.4.2
- correct firebase_options.dart android appId from the stale
com.halobestie.client.client_app registration to the com.mybestie app id
(1:1068156046511:android:4f8fe9a3c7c14c57b8185a) so the Dart [DEFAULT]
app matches google-services.json
Verified: google_app_id now merges into R.txt and logcat FA shows
"App measurement enabled ... Uploading data".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Firebase Analytics (GA4) funnel tracking to client_app:
- AnalyticsService typed wrapper (enum-gated, no PII) + analyticsProvider
- FirebaseAnalyticsObserver on GoRouter (screen_name via nameExtractor)
- user_id = customer UUID, user_type property, set on auth resolve/upgrade
- funnel events: curhat_start, curhat_repeat_start, auth_*, onboarding_usp_view,
payment_view, payment_method_select, payment_started, pairing_matched/no_bestie
- bottom-sheet events: verif_choice_view/select, bestie_choice_view/select,
extension_offer_view, chat_extension_requested
- payment_started carries app_instance_id + ga_session_id in the
/payment-requests body for future server-side stitching (backend ignores)
- curhat_mode_pick screen name disambiguates the chat/call mode picker
(/payment/method-pick) from the payment-channel picker (/payment/method)
- unify both home CTAs to "Aku Mau Curhat"
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace splash_chat_hebat with assets/icons/logo.png on @color/ic_launcher_background (customer #FF699F pink, mitra #FFFFFF white) across launch_background.xml (x2) and values-v31/styles.xml in both apps; copy logo.png into res/drawable. The mitra Flutter /splash screen still showed the old image — repoint it to assets/icons/logo.png (add assets/icons/ to mitra pubspec), keeping the route (it is the auth-loading gate). Native + flutter splash now match the launcher icon. Old splash_chat_hebat.png left in place but unused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paymentCatalogProvider was a plain FutureProvider, which Riverpod caches for the whole app session — so control-center enable/disable/create of payment methods only showed up after an app restart. Backend was already correct (every mutator calls invalidatePaymentCatalog). Switch to FutureProvider.autoDispose so the catalog is dropped when the payment page is popped and re-fetched on re-open. Only watched by the payment method screen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Belt-and-suspenders, not a bug fix: storage (timestamptz) and timer math are already tz-independent. Add SERVER_TZ env (default UTC) via getServerTimezone(); db/client.js pins the DB session timezone (reads env directly to avoid an import cycle); server.js pins process.env.TZ and asserts at boot that the DB session matches (logs [tz] or a loud warning). Keeps any future date_trunc/::date reporting deterministic and surfaces a misconfigured server early. Documented in backend/CLAUDE.md + .env.example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TransactionType.FIRST_SESSION_DISCOUNT ('first_session_discount', 22 chars) overflowed the VARCHAR(20) column, throwing in acceptPairingRequest AFTER the session was flipped to ACTIVE but before startSessionTimer/startSessionListener/PAIRED-notify ran. Every first-session-discount pairing thus half-completed: lost transaction row, no server-side timer, and a 500 to the mitra so its app never opened the chat. Widen the column (CREATE TABLE + idempotent ALTER). Deferred hardening (bookkeeping INSERT in the critical path) logged in TECH_DEBT.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live chat bubbles read createdAt.hour/.minute directly, but server created_at (UTC, ISO-Z) was parsed without .toLocal() while optimistic sends used DateTime.now() (local). On any non-UTC device, your own messages showed local time and received/history messages showed UTC within the same conversation. Add .toLocal() at the history-load + incoming-WS parse sites in both apps so bubbles match the optimistic path and the transcript view. Session timer math was already tz-safe (Dart .difference uses absolute instants).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Test-OTP bypass allowlist for Apple reviewers / QA: phone-scoped static OTPs
managed in CC (Settings → Test OTP Bypass), bcrypt-hashed on save, kill-switch
toggle, per-entry expires_at. New `otp_requests` columns (is_bypass, code_hash)
+ DB CHECK enforcing bypass-row shape.
- Hash-at-rest for stub OTPs: replaced plaintext `<ref>:<code>` storage with
bcrypt(code_hash); reference goes to fazpass_reference alone. Verify routes on
sovereign is_bypass flag, defers code_hash-NULL rows to Fazpass.
- Fazpass integration (gated by FAZPASS_ENABLED env, default off): new
fazpass.service.js calling /v1/otp/{request,verify}; distinct errors for wrong
OTP (CODE_MISMATCH 401) vs provider outage (OTP_PROVIDER_FAILED 502).
- Removed redundant Free Trial CC section (was a back-compat shim for the same
pricing_promotions row as "Diskon Sesi Pertama") + unused alias in
pricing.service.js.
208 tests green (34 new for OTP + Fazpass). Fazpass API + dashboard PDFs added
at project root for reference (docs are auth-gated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
- Backend wraps idn-finlogos npm at /assets/payment-icons/<slug>.svg with
1y immutable cache. Mobile drops bundled SVGs (only placeholder remains)
and fetches via flutter_cache_manager. payment_methods.icon is now a
CSV of slugs; catalog emits icon_urls[]. CARDS tile renders Visa + MC +
JCB side by side.
- Per-method min/max amount bounds (BIGINT, nullable). Picker greys out
out-of-range tiles with subtitle; backend gates with INVALID_PAYMENT_AMOUNT
(422). Defense in depth against stale-catalog clients.
- Xendit channel codes corrected from authoritative docs
(BCA_VA -> BCA_VIRTUAL_ACCOUNT, CREDIT_CARD -> CARDS, ovo -> ovo-new,
shopeepay -> shopee-pay, ...). 18 methods x 5 groups seeded with
Xendit-published per-channel min/max.
- Re-runnable seed (ON CONFLICT DO NOTHING on payment_code + new unique
index on group name). Operator CC edits never clobbered across re-runs.
One-shot reset + inspect scripts under backend/.dev/.
- Customer redirect HTML pages at /payment/return/{success,failure},
brand-styled with "Buka HaloBestie" CTA firing halobestie:// deeplink.
URL scheme registered on Android (intent-filter w/ BROWSABLE on
MainActivity) and iOS (CFBundleURLTypes). Waiting-payment poller still
owns confirmation; deeplink just brings the activity to foreground.
- Control center payment-catalog page: min/max inputs + columns. Other
CC pages restyled with new theme tokens (separate work, bundled here).
169/169 backend tests pass. See requirement/phase5-payment-revamp-2026-05-27.md
for the full revamp doc. Stage 8 (E2E) still pending: webhook URL routing
decision + two client_app follow-ups (legacy /chat/request removal,
extension Custom Tab).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Payment catalog (Phase 5.x — see requirement/phase5-payment-catalog-plan.md):
- New tables payment_method_groups + payment_methods with seed (3 groups,
10 methods; GoPay seeded inactive pending Xendit channel confirmation).
- payment-catalog.service.js with two-layer cache (60s in-process + 1h
Valkey) and config:invalidate pub/sub fanout. Mutator API + casing-
tolerant findActiveMethodByCode for downstream validation.
- App-facing GET /api/client/payment-methods returns pre-grouped JSON,
active-only, empty groups dropped server-side.
- POST /api/client/payment-requests now validates `method` against the
catalog (INVALID_PAYMENT_METHOD 422) and stamps
product_metadata.preferred_payment_code (upper-cased).
- Control-center /internal/payment-{groups,methods}{,/:id,/reorder}
endpoints (full CRUD + idempotent reorder). New Payment Catalog page
wired into the CC nav.
- Customer app renders the catalog as collapsible groups (first expanded)
via paymentCatalogProvider; QRIS-only hardcoded fallback on 5xx so
checkout never hard-fails. Replaces the hardcoded _PayMethod enum.
- 10 brand SVGs (~63KB) bundled in client_app/assets/payment_icons/ from
github.com/hafidznoor/idn-finlogos. Xendit's per-channel media-asset
pages were planned but found decommissioned during implementation —
switched to idn-finlogos with the standard "channels-we-accept"
trademark posture. See assets/payment_icons/README.md for the workflow
to add new methods.
- 16 vitest cases covering the service + cache; full backend suite green
(162/162).
Customer-app splash + register polish:
- Splash rewritten per figma S1: warm vertical gradient, two ImageFiltered
radial orbs, 96×96 rounded-square logo tile, "HaloBestie" + "kamu gak
harus ngerasain ini sendirian." Self-driving navigation via context.go
after a 2.5s post-frame timer (native Android splash burns ~1-1.5s
before Flutter paints — 1s timer yielded near-zero visible duration).
Router early-returns null for isSplash so it never moves us off /splash
on its own.
- 3-page onboarding carousel removed: user clarified the new splash
REPLACES that carousel. Dropped /onboarding route, OnboardingScreen,
onboardingDoneProvider + gating, dead splash_{1,2,3}.png + the
splash_chat_hebat.png Flutter asset. Phase 4 /onboarding/* subroutes
untouched; Android-native launch_background drawable left alone.
- Register screen (login-by-phone) polished: circular pink back button +
72×72 logo badge (same brandLogoBg pink as splash, Transform.scale 1.4
to fill the tile). Step-dots indicator removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the operator sets require_mitra_ping=false, the auto-offline sweep
early-returns (by design — "don't gate online status on heartbeat
freshness"). The three Valkey read paths still gated on heartbeat
freshness anyway, which trapped the system: sweep won't remove the
mitra from mitras:online, but readers reject them as stale. The customer
CTA stayed permanently disabled with no recovery.
Fix all three to skip the heartbeat-freshness check when require_ping
is off, matching the sweep's contract:
- computeAvailabilityFromValkey (customer beacon)
- isMitraReachable (extension service)
- findAvailableMitrasFromValkey (pairing candidate finder)
The Postgres fallbacks already did the right thing (is_online only,
no heartbeat compare); this aligns the Valkey hot path.
Also: PATCH /internal/config/mitra-ping now publishes config:invalidate
for require_mitra_ping and mitra_stale_after_seconds, and the subscriber
in mitra-status.service was widened to listen for both. Flipping the
toggle in CC now busts the 10s availability snapshot immediately instead
of waiting out the TTL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every Xendit invoice now carries metadata: { app: 'halobestie_v2' } so an
external webhook router (no DB access) can fan out v1/v2 traffic purely off
the echoed payload.
Every inbound webhook lands in a new webhook_logs table BEFORE auth or
business logic, so a forensic row survives 401/409/unknown/exception paths.
Primary fields are parsed as columns; raw_body keeps the full payload
verbatim. The handler captures outcome in closure-scoped vars and stamps
http_status/processing_result/processing_error in a single update before
the lone reply.send() — Fastify flushes reply.send() immediately, which
defeated the original finally-block stamp.
A non-UUID external_id no longer crashes the Postgres cast; it ACKs with
ignored_non_uuid_external_id so Xendit stops retrying legacy old-app IDs.
When the DB log itself fails, an optional rolling JSONL file sink absorbs
the event. Disabled by default — opt in via XENDIT_WEBHOOK_FALLBACK_ENABLED.
Naming: <NAME>-YYYY-MM-DD.jsonl in XENDIT_WEBHOOK_FALLBACK_DIR (default
./logs), basename XENDIT_WEBHOOK_FALLBACK_NAME (default
xendit-webhook-fallback). No stdout fallback by design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend
- payment_sessions → payment_requests rename across DB schema + 29 files
- payment.service.js becomes product-agnostic owner: EventEmitter +
Xendit wrapper + requestPayment / confirmPayment public API; legacy
aliases retained for existing chat callers
- Webhook handler at POST /api/shared/payment/webhooks/xendit, with
constant-time token verification (8 vitest cases)
- Server-driven pairing: payment.service emits
payment_request.confirmed → pairing subscriber starts the blast.
Legacy POST /chat/request still works during the cutover.
- Reconciliation sweeper extended (re-emits events for confirmed rows
with no chat session)
- SIGTERM drain + startup reconciliation pass in server.js
Customer app
- waiting_payment_screen opens xendit_invoice_url via
LaunchMode.inAppBrowserView
- searching / no-bestie / targeted-waiting / pairing-notifier updated
to consume the new payment_request_id contract
- pending_payments_provider + bestie-unavailable dialog migrated
Dev / testing
- XENDIT_ENABLED=false is the safe default; .env.example documents the
four new vars
- backend/.dev/xendit-fake-webhook.sh exercises the handler without
ngrok
- 90/92 backend tests pass (two pre-existing session-timer flakes,
unrelated); client_app analyzer clean
- requirement/phase5-xendit-plan.md is the canonical reference
Stage 8 (live E2E) blocked on Xendit test-mode keys. The dashboard's
single-webhook-URL constraint will be worked around via a self-poll
script next session.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Anonymous customers now see a brand-gradient "Simpan Nomor HP" panel
above the user card on the kamu tab, ported from the Figma SProfile
save-phone banner. Tapping it pushes /auth/register?from=profile, which
hides the "lanjut tanpa verifikasi (harga normal)" link — a user who
re-entered the verif funnel from Profile shouldn't be re-offered the
anon exit. Spec §1.3 added documenting the ?from= entry-point
convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- mitra_app/CLAUDE.md: pitfall entry for the InputDecorationTheme
min-height collision that broke chat-input centering. Walks through
the working recipe (constraints: BoxConstraints(), Material +
StadiumBorder + Center wrapper). Points at chat_screen.dart::_InputBar
in both apps as the source of truth.
- backend/CLAUDE.md: two new convention sections.
- Config-source: when to use DB-stored (operator-tunable via CC) vs
env-driven (deploy-fixed). Codifies the pattern shipped today for
MITRA_HEARTBEAT_CADENCE_SECONDS so Xendit credentials / callback
tokens follow the same shape tomorrow.
- FCM channel: single shared `halobestie_chat_v1` channel for both
apps, target via android.notification.channelId. Bump the channel
ID when introducing a new sound (Android API 26+ binds sound at
channel-create time).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The missing piece was `constraints: BoxConstraints()` on the
InputDecoration. The app-wide InputDecorationTheme in halo_theme
sets a 48dp min-height for form fields, which the chat input pill
doesn't want. Without explicitly nulling that constraint, the
TextField refuses to collapse below 48dp, so the line-box can't
sit on the parent 44dp container's midline — textAlignVertical
becomes a no-op and the text anchors top.
Switched to the same Material + StadiumBorder + Center wrapper
client_app already uses (chat_screen.dart::_InputBar). Verified
on emulator-5556 driving typed "halo" — text body now sits
visually centered on the pill midline.
Reverts the empirical TextAlignVertical(y: 0.4) shim from 75343f9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 92da8b2. With `textAlignVertical: center` + `isDense:true`,
the TextField was centering the line-box baseline on the parent
midline — but Latin lowercase glyphs sit at ~75% of line height,
leaving descender space empty below and the optical center of text
visibly above the pill midline.
Fix: `textAlignVertical: TextAlignVertical(y: 0.4)` shifts the
baseline down to align Latin x-height optical centers with the pill
midline. Also added explicit `alignment: Alignment.center` on the
Container so the field's small intrinsic line-box positions on the
midline rather than docking to the top.
Verified on emulator-5556 driving the typed "halo" through the chat
input — text body now sits on the visual midline of the 44dp pill.
The horizontal underline below typed text is Gboard's composing-
region indicator (Android IME behavior), not a TextField underline,
and will go away once the user commits the word with space/send.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit applied the pill/center fixes to the wrong textbox
(goodbye composer). Reverted that and applied to the actual chat
message input bar.
- Container bg: HaloTokens.bg → HaloTokens.surface. The pill now sits
on white against the cream page (HaloTokens.bg) so the outline reads
as a proper pill, not a faint shadow. Border color unchanged
(HaloTokens.border, the previous "shadow" tone).
- TextField wrapped in Center widget. textAlignVertical:center +
isCollapsed:true alone don't center the field against the parent
44dp container height — they only center within the field's own
intrinsic line-box, which then docks top of the parent. Center
delegates vertical placement to the container's stack, so the hint
lands on the vertical midline. textAlignVertical removed (Center
now owns alignment).
Goodbye composer (Pesan Penutup) restored to its prior styling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four small fixes on the mitra chat screen, all surfacing through the
chat connected-state.
1. AppBar customer name. The hardcoded "Customer" only ever came from
the FCM-tap navigation fallback (notification_service:
`extra: {'customerName': 'Customer'}`); the popup-overlay path passes
the real name but FCM had no way to know it. /chat/:sessionId/info
already returns `customer_display_name` — propagate it into
MitraChatConnectedData and read in the AppBar via .select. Falls
back to the route arg for the brief connecting window.
2. SISA WAKTU stuck at --:--. The pill watches a remaining-seconds
provider that's only updated by backend WS frames. Backend only
fires session_timer at 3-min + 1-min warnings + expiry, so the pill
sat at --:-- for the first ~7 minutes of a 10-min chat. Added a
local 1s ticker in the notifier that drives the provider against
expires_at (also pulled from /info). WS warning frames still
overwrite normally on top.
3. Pesan Penutup textbox. Replaced the rounded-rect OutlineInputBorder
field with a fixed-height Container pill whose border matches the
previous "shadow" tone (HaloTokens.border). Pill borderRadius is
the full 100 (was 12).
4. Goodbye textbox text was top-aligned because maxLines: 3 +
OutlineInputBorder left vertical alignment to InputDecoration's
built-in padding. Switched to maxLines: 1 + textAlignVertical:
center + isCollapsed: true inside the fixed-height container —
text now sits on the vertical center.
Bonus: the goodbye subhead "Tuliskan pesan terakhirmu untuk Customer"
also picked up the real name ("…untuk Andi Pratama").
Verified end-to-end on emulator-5556 (TestMitra-1501 + customer
"Andi Pratama"): AppBar shows Andi Pratama, SISA WAKTU ticks (04:57 →
04:35 across screenshots), goodbye pill renders with centered hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits the single mitra_ping_interval_seconds config (which conflated
"how often the app pings" with "how long until offline" through a
hidden ×3 multiplier) into two orthogonal knobs:
- mitra_stale_after_seconds (CC-tunable, app_config DB row): the
operator-facing offline threshold. What you set is what you get —
no multiplier. Default 45s (preserves today's effective grace at
the legacy 15s ping default).
- MITRA_HEARTBEAT_CADENCE_SECONDS (env var, default 30s): how often
the mitra app sends a heartbeat. Backend-fixed per deployment;
surfaced to the mitra app via /api/mitra/status.
Backend:
- config.service: getMitraPingConfig returns the new tuple
{require_ping, stale_after_seconds, heartbeat_cadence_seconds}.
Env parser handles blank/non-numeric → 30 fallback.
- mitra-status.service::autoOfflineStaleMitras drops the *3 and uses
stale_after_seconds directly.
- mitra-status.service::getStatus returns heartbeat_cadence_seconds
instead of ping_interval_seconds.
- /internal/config/mitra-ping PATCH validates
stale_after_seconds >= cadence, returns 422 with a clear message
("stale_after_seconds must be a number >= heartbeat cadence (30s)").
- migrate.js: adds mitra_stale_after_seconds default 45. The old
mitra_ping_interval_seconds key is left in place (vestigial) —
no live code reads it; safe to drop after one release.
Mitra app:
- status_notifier reads heartbeat_cadence_seconds, uses it directly
as the Timer.periodic interval. Defaults to 30s if missing (older
backend safety).
Control center:
- SettingsPage: renames "Interval Ping" → "Ambang offline", input
min={heartbeat_cadence_seconds}, shows the cadence as a read-only
value with explanation that it's env-controlled.
Verified end-to-end on dev backend:
- GET /api/mitra/status returns {…, heartbeat_cadence_seconds: 30}
- GET /internal/config/mitra-ping returns {require_ping,
stale_after_seconds: 45, heartbeat_cadence_seconds: 30}
- PATCH with stale_after_seconds=20 → 422 with cadence message
- PATCH with stale_after_seconds=120 → 200, persisted
- Env override (=60, blank, "foo") parses correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The customer app now uses the same halobestie_notif.ogg as the mitra
app (shipped in the previous commit). Channel ID unified across both
apps so backend FCM stops branching per recipient.
- client_app: same channel bump (chat_messages → halobestie_chat_v1)
+ RawResourceAndroidNotificationSound binding, both at channel-
create time and per-notification details. .ogg copied to
client_app/android/app/src/main/res/raw/halobestie_notif.ogg
(same 32 KB asset, identical file).
- Backend: drop the per-recipientType channel ID branch; everyone
targets halobestie_chat_v1 now.
Verified on emulator-5554 (customer): dumpsys shows the channel
bound to android.resource://com.mybestie/raw/halobestie_notif.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the system notification ding for incoming mitra FCM (Curhat Baru,
Permintaan Perpanjang) and plays the HaloBestie audio mark instead.
Source: a 2.8s mono AAC inside a 3GPP container the user supplied;
converted to 32 KB OGG (Vorbis q5) for Android since the channel-sound
API needs `res/raw/<name>.<ext>` and OGG is the smallest universally
supported short-sound format on Android 5+.
- mitra_app: bump notification channel ID from `chat_messages` to
`halobestie_chat_v1` (Android binds channel sound at create time
on API 26+, so existing installs with the old channel need a fresh
ID to pick up the new sound — can't mutate in place). Bind
RawResourceAndroidNotificationSound('halobestie_notif') at both
channel-create time and per-notification details (latter covers
API 24/25 where channels don't exist).
- Backend: branch FCM `android.notification.channelId` by recipient
type — mitras → `halobestie_chat_v1`, customers → `chat_messages`
(unchanged). Customer app keeps system sound until/unless we ship
a customer-side sound too.
Verified on emulator-5556 via `adb shell dumpsys notification` — the
new channel resolves to
`android.resource://com.mybestie.mitra/raw/halobestie_notif`. The OGG
ships inside the APK (32092 bytes, confirmed via `unzip -l`).
Follow-up (iOS): bundle the same sound as `.caf` under ios/Runner +
register as a Runner-target resource in pbxproj + reference filename
in the APS payload. Deferred until iOS testing comes back into scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the generic chat_bubble + send Material icons with the
official WhatsApp + Telegram glyphs from font_awesome_flutter. Adds
the package as a runtime dep; FA brand glyphs are CC BY 4.0 and the
package itself is MIT.
Visual style is kept consistent with the other rows (pink-soft tile
backing, brand-pink glyph fill) rather than full-brand colors —
matches the figma's monochrome tile pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback — the wa.me/... and t.me/... subtitles under "Chat
WhatsApp Kami" / "Chat Telegram Kami" leaked the raw URL into the UI.
Just the label now, matching how typical "contact us" menu entries
read. Tap still launches the deeplink from backend config.
Drop the unused `SupportHandle.displayHandle` getter that produced the
scheme-stripped subtitle — no other call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the placeholder "Hubungi Koordinator" row with two real
contacts pulled from backend config (support_handles_json), and drops
the "Hapus Akun" CTA. Mirrors the figma BestieProfile design but uses
the same WA/TG channel as the customer Tanya Admin sheet — business
decided the same ops team triages both audiences.
Backend:
- Promote support-handles route from /api/client to /api/shared
(renamed file + export). Both apps now consume the same endpoint;
hitting /api/client/* from mitra would violate the per-app
convention in mitra_app/CLAUDE.md.
- client_app provider updated to /api/shared/support-handles.
Mitra app:
- New support_handles_provider mirroring the client_app one. Adds a
`displayHandle` getter that strips the URL scheme for the subtitle
("https://wa.me/X" → "wa.me/X", "https://t.me/Y" → "t.me/Y") so the
row looks like the figma without exposing raw URLs.
- Profil screen now lists: Chat WhatsApp Kami, Chat Telegram Kami,
Syarat & Ketentuan, Kebijakan Privasi. Danger zone simplified to
Keluar only — mitras request account deletion through the same
WA/TG channels (no separate self-service path).
- url_launcher added as a runtime dep, launches deeplinks in
externalApplication mode with graceful snackbar fallback when
parsing or launching fails.
Updates [[feedback-mitra-internal-audience]] — pre-login rule still
holds (no admin CTAs on S3a/S3b/AccountInactive), but the post-login
Profil tab now does surface WA/TG. Overrides decided 2026-05-21.
Verified on emulator-5556: Profil tab renders both rows with handles
from `wa.me/6285173310010` + `t.me/halobestie`, Keluar present, no
Hapus Akun button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
- 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>
§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>
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>
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>
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>
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>
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>