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>
5.4 KiB
Halo Bestie — Backend
Fastify.js REST API serving both mobile apps and the internal control center.
See root
CLAUDE.mdfor full project context and architectural decisions.
Stack
- Runtime: Node.js + Fastify.js
- Database: PostgreSQL via GCP Cloud SQL
- Auth: Self-managed JWT (HS256 access, 1h) + opaque refresh token (30d, rotated, bcrypt-hashed in
auth_sessions). Firebase Auth removed in Phase 3.4 (commitf860ab6).firebase-adminis kept but only for FCM messaging. - Payment: Xendit
- Infra: GCP Cloud Run
Two Listeners
Public (0.0.0.0:3000) → client_app + mitra_app routes
Internal (private IP:3001) → control_center routes only
Internal listener must never be exposed to the public internet.
Route Namespacing
/api/client/... → client app routes
/api/mitra/... → mitra app routes
/api/shared/... → shared routes (e.g. auth, refresh, logout, anonymous)
/internal/... → control center routes (internal listener only)
Auth Flow
- Mobile (client/mitra):
Authorization: Bearer <access_token>header. Access token is our own JWT (HS256,AUTH_JWT_SECRET), with claims{ sub, user_type, session_id }. Refresh viaPOST /api/shared/auth/refreshwith the opaque refresh token in the body. - Control center: Access token in
Authorization: Bearer(kept in memory by the SPA). Refresh token lives in anhttpOnlySecure cookie; refresh callsPOST /internal/auth/refreshwithcredentials: 'include'. - Entry points:
- Anonymous customer:
POST /api/shared/auth/anonymous - Phone OTP (customer/mitra):
/api/{client,mitra}/auth/otp/{request,verify}— Fazpass is stubbed inotp.service.js; code is logged to the backend console ([OTP STUB] phone=… code=…) until real API docs arrive. - Google/Apple:
/api/client/auth/{google,apple}(client_app only — creds pending) - CC login:
POST /internal/auth/login(email + bcrypt password)
- Anonymous customer:
- Middleware:
authenticateplugin verifies the JWT and attachesrequest.auth = { userType, userId, sessionId }. WebSocket handshake uses the same verification. No DB lookup on every request — the user ID is encoded in the token.
Key Conventions
- All routes must be authenticated unless explicitly marked public (auth + anonymous routes are the exceptions)
- Internal routes additionally require
request.auth.userType === 'cc_user' - Use Fastify plugins for shared middleware (auth, error handling, logging)
- Business logic lives in
services/— never directly in route handlers - Never reintroduce Firebase Auth.
firebase-adminis FCM-only; do not import.auth()from it.
Config-Source Convention
Two distinct knob-types exist; do not conflate them:
- DB-stored (
app_configtable, mutable via CC SettingsPage at runtime): used for operator-tunable values that may change between deploys without a code roll —mitra_stale_after_seconds,extension_timeout_seconds,pricing_tiers,support_handles_json,max_customers_per_mitra, etc. Read via getters inservices/config.service.js. Cache invalidation goes throughvalkeypub/sub when needed. - Env-driven (
process.env, set per deployment via.envor Cloud Run env vars): used for deploy-fixed values that should never differ between operator actions —MITRA_HEARTBEAT_CADENCE_SECONDS,FIREBASE_SERVICE_ACCOUNT_PATH,AUTH_JWT_SECRET,DATABASE_URL. Always expose via a getter helper with a sane default + numeric parsing (seegetMitraHeartbeatCadenceSecondsin config.service.js for the pattern).
When a new value needs to flow from CC → app, prefer DB. When it's a deploy-fixed contract (e.g. heartbeat cadence the apps must honor, Xendit credentials, callback tokens), prefer env. CC inputs that depend on env values (e.g. min/max validation) read the env-derived value via the same config endpoint that surfaces the DB value, and the PATCH route validates against it.
FCM Channel Convention
Single channel halobestie_chat_v2 is shared by both apps and ships the branded halobestie_notif.ogg sound. Backend FCM payloads should always target this channel ID via android.notification.channelId:
android: { priority: 'high', notification: { channelId: 'halobestie_chat_v2' } }
Channel must be created from native Android (Kotlin) code, not from Dart via flutter_local_notifications. The plugin's AndroidNotificationChannel sets AudioAttributes with CONTENT_TYPE_UNKNOWN; on Android 13+ (API 33) this causes notifications to post and be tagged isNoisy=true, but systemui never requests audio focus and the sound is silently dropped. The native channel must use setContentType(CONTENT_TYPE_SONIFICATION) alongside USAGE_NOTIFICATION. See MainActivity.kt in both client_app and mitra_app. The Dart-side AndroidNotificationChannel definition stays in notification_service.dart so flutter_local_notifications.show() resolves the channel id, but its createNotificationChannel call is a no-op since the native channel already exists (channels are immutable on API 26+).
Do not introduce per-recipient or per-feature channels lightly. If a new sound is required (e.g. payment alert), bump the channel ID (halobestie_chat_v3) and update both apps' native MainActivity + Dart definition + backend simultaneously — Android binds channel sound at create-time on API 26+, so mutating the existing channel doesn't pick up the new sound for installed users.