Compare commits
5 Commits
6fd98ca99c
...
76d74aa7b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 76d74aa7b5 | |||
| 22048c678f | |||
| 529a38ae3f | |||
| 495eb98787 | |||
| 6e87e9b6da |
24
TECH_DEBT.md
24
TECH_DEBT.md
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
client_app/android/app/src/main/res/drawable/logo.png
Executable file
BIN
client_app/android/app/src/main/res/drawable/logo.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
mitra_app/android/app/src/main/res/drawable/logo.png
Executable file
BIN
mitra_app/android/app/src/main/res/drawable/logo.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -72,6 +72,7 @@ flutter:
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/images/splash/
|
||||
- assets/icons/
|
||||
- assets/fonts/
|
||||
|
||||
fonts:
|
||||
|
||||
Reference in New Issue
Block a user