Files
halobestie-clone/backend/CLAUDE.md
Ramadhan Sjamsani 529a38ae3f feat(backend): pin server timezone to UTC with startup assertion
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>
2026-06-01 22:27:16 +08:00

82 lines
6.9 KiB
Markdown

# 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.
## Timezone
**The backend is UTC end-to-end, and that is independent of the server/OS timezone.**
- All timestamp columns are `TIMESTAMPTZ`, which stores an absolute UTC instant (no per-row zone). Storage does NOT depend on the session/server timezone.
- All timestamp writes use server-computed instants (`NOW()`, `NOW() + interval`), never app-supplied wall-clock. There is no session-tz-dependent SQL (`date_trunc` / `::date` / `CURRENT_DATE` / `AT TIME ZONE`) anywhere today, so correctness does not rely on the timezone setting.
- The `postgres` driver returns JS `Date` (an absolute instant); Fastify serializes it via `.toISOString()`, so the API always emits ISO-8601 with a `Z`. Flutter parses that to a UTC `DateTime` and `.toLocal()`s **only at display time**. Rule for the apps: store/transport UTC, convert to local only when rendering a wall-clock.
`SERVER_TZ` (env, default `UTC`) is **belt-and-suspenders**, not a fix for any live bug: `db/client.js` pins the DB session timezone and `server.js` pins `process.env.TZ` to it, then asserts at boot that the DB session matches (logs `[tz] …` / a loud warning otherwise). This keeps any *future* `date_trunc`/`::date`-style reporting deterministic and surfaces a misconfigured server early. Getter: `getServerTimezone()` in `config.service.js` (`db/client.js` reads the env directly to avoid an import cycle — keep the `UTC` default in sync). The thing that genuinely matters operationally is NTP clock sync, not the timezone — a wrong wall-clock breaks `NOW()` and timers; a wrong timezone does not.
## 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`:
```js
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.