Compare commits

...

5 Commits

Author SHA1 Message Date
76d74aa7b5 chore(splash): use app logo on icon background for native + flutter splash
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>
2026-06-01 22:27:39 +08:00
22048c678f fix(payment): autoDispose payment catalog so CC edits reflect without app restart
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>
2026-06-01 22:27:26 +08:00
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
495eb98787 fix(db): widen customer_transactions.type to VARCHAR(128)
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>
2026-06-01 22:27:07 +08:00
6e87e9b6da fix(chat): render message timestamps in device-local time
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>
2026-06-01 22:26:57 +08:00
20 changed files with 143 additions and 25 deletions

View File

@@ -10,6 +10,30 @@ to act on it without re-deriving the discussion.
## Backend
### `[2026-06-01]` Bookkeeping INSERT sits in the pairing critical path
**File:** `backend/src/services/pairing.service.js` (`acceptPairingRequest`, ~line 506)
**What happened:** the `INSERT INTO customer_transactions` runs *after* the session
is flipped to `ACTIVE` but *before* `startSessionTimer`, `startSessionListener`,
the customer `PAIRED` WS notify, and the other-mitra dismiss fan-out. A
`varchar(20)` overflow on `type = 'first_session_discount'` (22 chars) threw
there, so every first-session-discount pairing half-completed: no transaction
row, no server-side timer, no PAIRED push (customer recovered via polling), and a
500 returned to the mitra so its app never opened the chat.
**Fixed now:** column widened to `VARCHAR(128)` (migrate.js), so the INSERT no
longer throws.
**Why it's still debt:** a *bookkeeping* write can still abort *critical* pairing
steps if it ever fails again (constraint change, DB hiccup, future longer enum).
Hardening: either move the `customer_transactions` INSERT to the end of
`acceptPairingRequest`, or wrap it in a `try/catch` that logs-but-doesn't-throw,
so transaction recording can never again half-complete a pairing. Same applies to
the equivalent INSERT in `extension.service.js`.
---
### `[2026-05-11]` Public `GET /api/public/bestie/available` needs rate limiting before prod
**File:** `backend/src/routes/public/public.bestie-availability.routes.js`

View File

@@ -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

View File

@@ -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`:

View File

@@ -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
}

View File

@@ -226,12 +226,19 @@ const migrate = async () => {
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
session_id UUID NOT NULL REFERENCES chat_sessions(id),
type VARCHAR(20) NOT NULL,
type VARCHAR(128) NOT NULL,
amount INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
// Idempotent widen for DBs created when this column was VARCHAR(20): the
// TransactionType.FIRST_SESSION_DISCOUNT value 'first_session_discount' is
// 22 chars and overflowed varchar(20), throwing in acceptPairingRequest()
// *after* the session was already marked ACTIVE — losing the transaction row,
// the server-side timer, the PAIRED WS notify, and returning 500 to the mitra.
await sql`ALTER TABLE customer_transactions ALTER COLUMN type TYPE VARCHAR(128)`
await sql`
CREATE INDEX IF NOT EXISTS idx_customer_transactions_customer_id
ON customer_transactions (customer_id)

View File

@@ -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()

View File

@@ -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

View File

@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- Same pink as the launcher icon background (ic_launcher_background = #FF699F),
so the native launch splash matches the in-app logo tile. -->
<item android:drawable="@color/ic_launcher_background" />
<!-- Square box (logo.png is 4500x4500) so the glyph isn't distorted. -->
<item
android:width="200dp"
android:height="168dp"
android:height="200dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@drawable/splash_chat_hebat" />
android:src="@drawable/logo" />
</item>
</layer-list>

View File

@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- Same pink as the launcher icon background (ic_launcher_background = #FF699F),
so the native launch splash matches the in-app logo tile. -->
<item android:drawable="@color/ic_launcher_background" />
<!-- Square box (logo.png is 4500x4500) so the glyph isn't distorted. -->
<item
android:width="200dp"
android:height="168dp"
android:height="200dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@drawable/splash_chat_hebat" />
android:src="@drawable/logo" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowSplashScreenBackground">@android:color/white</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_chat_hebat</item>
<item name="android:windowSplashScreenBackground">@color/ic_launcher_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/logo</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>

View File

@@ -245,7 +245,9 @@ class Chat extends _$Chat {
content: m['content'] as String,
type: m['type'] as String? ?? MessageType.text,
status: m['status'] as String? ?? MessageStatus.sent,
createdAt: DateTime.parse(m['created_at'] as String),
// Server sends UTC (ISO-8601 with Z); render in device-local time so
// bubbles match optimistic sends (DateTime.now()) + the transcript view.
createdAt: DateTime.parse(m['created_at'] as String).toLocal(),
)).toList();
final token = ref.read(authBridgeProvider).accessToken;
@@ -351,7 +353,8 @@ class Chat extends _$Chat {
content: data['content'] as String,
type: data['message_type'] as String? ?? MessageType.text,
status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String),
// UTC from server → device-local for display (see history-load note).
createdAt: DateTime.parse(data['created_at'] as String).toLocal(),
);
state = current.copyWith(messages: [...current.messages, msg]);
markDelivered([msg.id]);

View File

@@ -106,7 +106,15 @@ const PaymentCatalog kFallbackPaymentCatalog = _FallbackCatalog();
/// App-facing catalog. Calls `GET /api/client/payment-methods`; on 5xx or
/// network error returns [kFallbackPaymentCatalog] so checkout never
/// hard-fails. See `requirement/phase5-payment-catalog-plan.md` §5.
final paymentCatalogProvider = FutureProvider<PaymentCatalog>((ref) async {
///
/// `autoDispose`: a plain FutureProvider caches its result for the whole app
/// session, so control-center edits to payment methods (enable/disable/create)
/// only showed up after an app restart. autoDispose drops the cached catalog
/// once the payment screen is popped (no listeners), so re-opening the payment
/// page re-fetches the now-current catalog from the backend (whose own cache is
/// invalidated on every mutation).
final paymentCatalogProvider =
FutureProvider.autoDispose<PaymentCatalog>((ref) async {
final api = ref.read(apiClientProvider);
try {
final res = await api.get('/api/client/payment-methods');

View File

@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- Same background as the launcher icon (ic_launcher_background = #FFFFFF
for the mitra full-color logo), so the native launch splash matches. -->
<item android:drawable="@color/ic_launcher_background" />
<!-- Square box (logo.png is 4500x4500) so the glyph isn't distorted. -->
<item
android:width="200dp"
android:height="168dp"
android:height="200dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@drawable/splash_chat_hebat" />
android:src="@drawable/logo" />
</item>
</layer-list>

View File

@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- Same background as the launcher icon (ic_launcher_background = #FFFFFF
for the mitra full-color logo), so the native launch splash matches. -->
<item android:drawable="@color/ic_launcher_background" />
<!-- Square box (logo.png is 4500x4500) so the glyph isn't distorted. -->
<item
android:width="200dp"
android:height="168dp"
android:height="200dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@drawable/splash_chat_hebat" />
android:src="@drawable/logo" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowSplashScreenBackground">@android:color/white</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_chat_hebat</item>
<item name="android:windowSplashScreenBackground">@color/ic_launcher_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/logo</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>

View File

@@ -214,7 +214,9 @@ class MitraChat extends _$MitraChat {
content: m['content'] as String,
type: m['type'] as String? ?? MessageType.text,
status: m['status'] as String? ?? MessageStatus.sent,
createdAt: DateTime.parse(m['created_at'] as String),
// Server sends UTC (ISO-8601 with Z); render in device-local time so
// bubbles match optimistic sends (DateTime.now()) + the transcript view.
createdAt: DateTime.parse(m['created_at'] as String).toLocal(),
)).toList();
final token = ref.read(authBridgeProvider).accessToken;
@@ -329,7 +331,8 @@ class MitraChat extends _$MitraChat {
content: data['content'] as String,
type: data['message_type'] as String? ?? MessageType.text,
status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String),
// UTC from server → device-local for display (see history-load note).
createdAt: DateTime.parse(data['created_at'] as String).toLocal(),
);
state = current.copyWith(messages: [...current.messages, msg]);
markDelivered([msg.id]);

View File

@@ -1,5 +1,9 @@
import 'package:flutter/material.dart';
/// Loading gate shown by the `/splash` route while auth resolves on launch.
/// Visually matches the native Android launch splash (new logo on white), so
/// the user only ever sees one splash with the current icon — no flash of the
/// old `splash_chat_hebat` image.
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@@ -9,7 +13,7 @@ class SplashScreen extends StatelessWidget {
backgroundColor: Colors.white,
body: Center(
child: Image.asset(
'assets/images/splash_chat_hebat.png',
'assets/icons/logo.png',
width: 200,
),
),

View File

@@ -72,6 +72,7 @@ flutter:
assets:
- assets/images/
- assets/images/splash/
- assets/icons/
- assets/fonts/
fonts: