diff --git a/backend/.env.example b/backend/.env.example index 72f6658..ee4963d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,6 +6,12 @@ INTERNAL_HOST=127.0.0.1 # Database DATABASE_URL=postgresql://user:password@localhost:5432/halobestie +# Server timezone. Pins the DB session + Node process to one zone. Leave as UTC +# in all environments — storage (timestamptz) and timer math are tz-independent, +# this just keeps any future date_trunc/::date-style SQL deterministic. The +# backend asserts the DB session matches this at startup. +SERVER_TZ=UTC + # Valkey / Redis VALKEY_URL=redis://localhost:6379 diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index a689cad..f5ab271 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -58,6 +58,16 @@ Two distinct knob-types exist; do not conflate them: 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`: diff --git a/backend/src/db/client.js b/backend/src/db/client.js index e85e416..5a6e8e3 100644 --- a/backend/src/db/client.js +++ b/backend/src/db/client.js @@ -4,7 +4,13 @@ let sql export const getDb = () => { if (!sql) { - sql = postgres(process.env.DATABASE_URL) + // Pin the session timezone so timestamptz I/O and any future + // session-tz-dependent SQL (date_trunc / ::date / CURRENT_DATE) are + // deterministic regardless of the DB server's default. Defaults to UTC. + // Mirrors getServerTimezone() in config.service.js — kept inline here to + // avoid a client.js <-> config.service.js import cycle. + const timezone = (process.env.SERVER_TZ || 'UTC').trim() + sql = postgres(process.env.DATABASE_URL, { connection: { timezone } }) } return sql } diff --git a/backend/src/server.js b/backend/src/server.js index 46f583c..0424e13 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -11,13 +11,33 @@ import { import { initFirebase } from './plugins/firebase.js' import { restoreActiveTimers } from './services/session-timer.service.js' import { expireStalePaymentRequests, registerPairingSubscriber } from './services/payment.service.js' -import { getXenditConfig } from './services/config.service.js' +import { getXenditConfig, getServerTimezone } from './services/config.service.js' +import { getDb } from './db/client.js' const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000 const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001 const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1' const start = async () => { + // Timezone assurance. Storage (timestamptz) and our instant-based timer math + // are timezone-independent, so this is belt-and-suspenders, not a fix for a + // live bug: it pins the Node process timezone for any Date formatting and + // then asserts the DB session timezone matches, so a misconfigured server/DB + // surfaces loudly at boot instead of silently skewing future date_trunc / + // ::date style queries. Defaults to UTC; override via SERVER_TZ. + const serverTz = getServerTimezone() + process.env.TZ = serverTz + const [dbTz] = await getDb()`SHOW timezone` + if (dbTz?.TimeZone !== serverTz) { + console.warn( + `[tz] WARNING: DB session timezone is "${dbTz?.TimeZone}" but SERVER_TZ="${serverTz}". ` + + 'timestamptz storage is unaffected, but session-tz-dependent SQL may skew. ' + + 'Check the DB server default / connection options.' + ) + } else { + console.log(`[tz] process + DB session pinned to ${serverTz}`) + } + // Phase 5: fail fast if XENDIT_ENABLED=true without the required credentials. // Bad config explodes at startup rather than at the first /payment-requests POST. const xc = getXenditConfig() diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index ef44c2d..bcae8ee 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -114,6 +114,20 @@ export const getMitraHeartbeatCadenceSeconds = () => { return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30 } +// Server timezone. Defaults to UTC and should essentially never be changed — +// see backend/CLAUDE.md "Timezone" note. timestamptz storage and all of our +// instant-based timer math are timezone-INDEPENDENT, so this is belt-and- +// suspenders: it pins the DB session + Node process to one zone so that any +// FUTURE session-tz-dependent SQL (date_trunc / ::date / CURRENT_DATE) and any +// stray local-time Date formatting stay deterministic across deploys. +// NOTE: db/client.js reads `process.env.SERVER_TZ || 'UTC'` directly (it cannot +// import this module without a cycle); keep the default in sync. +export const getServerTimezone = () => { + const raw = process.env.SERVER_TZ + if (!raw || raw.trim() === '') return 'UTC' + return raw.trim() +} + // --- Valkey availability mirror — env-driven cadences --- // // Per requirement/valkey-online-mirror-plan.md. All three are operational