Files
halobestie-clone/backend/CLAUDE.md
Ramadhan Sjamsani 3a0cdf5c4e Phase 5/6 polish: end-session flow, notif sound on API 33+, Xendit webview
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>
2026-05-28 21:45:46 +08:00

5.4 KiB

Halo Bestie — Backend

Fastify.js REST API serving both mobile apps and the internal control center.

See root CLAUDE.md for 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 (commit f860ab6). firebase-admin is 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 via POST /api/shared/auth/refresh with the opaque refresh token in the body.
  • Control center: Access token in Authorization: Bearer (kept in memory by the SPA). Refresh token lives in an httpOnly Secure cookie; refresh calls POST /internal/auth/refresh with credentials: 'include'.
  • Entry points:
    • Anonymous customer: POST /api/shared/auth/anonymous
    • Phone OTP (customer/mitra): /api/{client,mitra}/auth/otp/{request,verify}Fazpass is stubbed in otp.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)
  • Middleware: authenticate plugin verifies the JWT and attaches request.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-admin is FCM-only; do not import .auth() from it.

Config-Source Convention

Two distinct knob-types exist; do not conflate them:

  • DB-stored (app_config table, 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 in services/config.service.js. Cache invalidation goes through valkey pub/sub when needed.
  • Env-driven (process.env, set per deployment via .env or 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 (see getMitraHeartbeatCadenceSeconds in 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.