Files
halobestie-clone/backend/src/routes/internal/_test.routes.js
Ramadhan Sjamsani fbc94daac7 Mitra Bestie §1–§3: shell + Undangan + popup + chat polish
Brings the mitra app to figma-bestie parity for Home (§1), Undangan
inbox with Curhat Baru + Perpanjang tabs (§2), and the incoming-popup
+ active-chat flow (§3). Home now lives inside a StatefulShellRoute
with BestieTabBar so Profil + Undangan + Home share one shell.

- Shell: features/shell/ (StatefulShellRoute, BestieTabBar, 3 branches)
- Undangan: features/undangan/ — Curhat Baru reads
  chatRequestProvider.pendingInvites; row Terima delegates accept to
  the notifier and ChatRequestOverlay owns nav (no double-push).
  Perpanjang tab stubbed (empty state) until backend exposes
  pendingExtensionsProvider.
- Profil: features/profile/ — Bestie-styled stub
- Home: refactored to body-only (shell owns chrome)
- Popup: chat_request_overlay + chat_request_notifier updated to
  serve the list rows, not just the modal
- Chat: mitra_chat_screen polish
- Theme: accentAmber tokens for the Perpanjang tab + halo_orb widget
  (loading spinner used by undangan list states)
- Login: replace broken GoRouterState location guard with
  _expectOtpPush flag — was stacking duplicate /otp pages on OTP
  resend (see project-otp-nav-bug-fixed-2026-05-21)

Maestro:
- 17 new flows under .maestro/flows/ts-mitra-{1,2,3}-* covering home
  online/offline variants, undangan empty/populated/tolak states,
  popup curhat-baru → accept → chat → ended banner, plus popup
  dismiss/expire/cancelled edge cases
- 4 new §A OTP flows (07/08/09/10) for invalid/mismatch/expired/cooldown
- Helper scripts: force_mitra_online/offline, force_pairing_timeout,
  force_session_expires_at, delete_mitra_status_row,
  customer_blast_now (js), customer_cancel_latest_blast
- Backend: POST /internal/_test/delete-mitra-status-row supports the
  "fresh mitra with no status row" test setup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:14:30 +08:00

632 lines
26 KiB
JavaScript

// Dev/test-only routes. Registration in app.internal.js is gated on
// NODE_ENV !== 'production' so these endpoints never exist in prod builds.
//
// Used by Maestro flows + curl harnesses to read state that the OTP stub
// keeps in memory (the stub-generated code per phone), without baking
// test phone numbers or fixed codes into production code paths.
import { peekStubOtp } from '../../services/otp.service.js'
import { acceptPairingRequest, expirePairingRequest, expireTargetedPairingRequest, getPendingRequestsForMitra } from '../../services/pairing.service.js'
import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js'
import { inspectSessionWsState } from '../../plugins/websocket.js'
import { sendMessage } from '../../services/chat.service.js'
import { sendPushNotification } from '../../services/notification.service.js'
import { getDb } from '../../db/client.js'
import { PairingFailureCause, SessionStatus, UserType } from '../../constants.js'
const sql = getDb()
export const internalTestRoutes = async (fastify) => {
fastify.get('/peek-otp', async (request, reply) => {
const phone = request.query?.phone
if (!phone) {
return reply.code(400).send({ error: 'phone query param required' })
}
const entry = peekStubOtp(phone)
if (!entry) {
return reply.code(404).send({ error: 'no_otp_for_phone', phone })
}
return entry
})
// Wipe rate-limit + cooldown state for a phone so flows can re-run quickly.
// Deletes otp_requests rows for the phone and (optionally) the customer row
// so identity-upgrade flows start fresh.
fastify.post('/reset-phone', async (request, reply) => {
const phone = request.body?.phone
if (!phone) {
return reply.code(400).send({ error: 'phone required in body' })
}
const dropCustomer = request.body?.drop_customer === true
await sql`DELETE FROM otp_requests WHERE phone = ${phone}`
if (dropCustomer) {
// Cascade through tables that FK-reference customer.id so re-runs
// don't trip 23503 from prior test artefacts.
const ids = await sql`SELECT id FROM customers WHERE phone = ${phone}`
for (const { id } of ids) {
await sql`DELETE FROM chat_messages WHERE session_id IN (SELECT id FROM chat_sessions WHERE customer_id = ${id})`
await sql`DELETE FROM chat_request_notifications WHERE session_id IN (SELECT id FROM chat_sessions WHERE customer_id = ${id})`
await sql`DELETE FROM customer_transactions WHERE customer_id = ${id}`
await sql`DELETE FROM chat_sessions WHERE customer_id = ${id}`
await sql`DELETE FROM payment_sessions WHERE customer_id = ${id}`
await sql`DELETE FROM auth_sessions WHERE user_id = ${id} AND user_type = 'customer'`
}
await sql`DELETE FROM customers WHERE phone = ${phone}`
}
return { ok: true, phone, dropped_customer: dropCustomer }
})
// Force-CONFIRM a `pending` payment session (used by Maestro flows that
// need to advance past the waiting-payment screen without going through
// real Xendit). Triggers the same downstream effects (pairing start) that
// a real webhook would.
fastify.post('/force-confirm-payment', async (request, reply) => {
const { latest } = request.body ?? {}
let target
if (latest === true) {
const [row] = await sql`
SELECT id FROM payment_sessions
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_pending_payment' })
}
target = row.id
} else {
return reply.code(400).send({ error: 'latest:true required in body' })
}
const [updated] = await sql`
UPDATE payment_sessions
SET status = 'confirmed', confirmed_at = NOW()
WHERE id = ${target} AND status = 'pending'
RETURNING id, customer_id, status, mode, duration_minutes, is_first_session_discount, targeted_mitra_id
`
if (!updated) {
return reply.code(409).send({ error: 'payment_state_changed' })
}
// Customer-app waiting screen polls and will advance once it sees
// `confirmed`. Pairing creation is the customer-app's responsibility
// (createPairingRequest is called from the searching screen).
return { ok: true, payment: updated }
})
// Force-expire a `pending` payment session (used by Maestro Stage 3 flow to
// drive the waiting-payment screen into the expired state without waiting
// 20 minutes). Sets `expires_at` to the past and status to `expired` so the
// next poll from the client sees the terminal state.
//
// Body shape:
// { payment_id: '<uuid>' } → expire this specific session
// { latest: true } → expire the most-recently-created pending
fastify.post('/force-expire-payment', async (request, reply) => {
const { payment_id, latest } = request.body ?? {}
let target
if (latest === true) {
const [row] = await sql`
SELECT id FROM payment_sessions
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_pending_payment' })
}
target = row.id
} else if (payment_id) {
target = payment_id
} else {
return reply.code(400).send({ error: 'payment_id or latest:true required in body' })
}
const [updated] = await sql`
UPDATE payment_sessions
SET status = 'expired', expires_at = NOW() - INTERVAL '1 minute'
WHERE id = ${target} AND status = 'pending'
RETURNING id, status
`
if (!updated) {
return reply.code(404).send({ error: 'no_pending_payment_for_id', payment_id: target })
}
return { ok: true, ...updated }
})
// Force-expire a pairing blast (used by Maestro Stage 5 flow to drive the
// searching screen into the timeout state without waiting 5 minutes). Marks
// the most-recently-created blast chat_session as no_mitra_available.
//
// Body shape:
// { session_id: '<uuid>' } → expire this specific session
// { latest: true } → expire the most-recent SEARCHING session
fastify.post('/force-pairing-timeout', async (request, reply) => {
const { session_id, latest } = request.body ?? {}
let target = session_id
if (latest === true) {
// Accept either `searching` (general blast in flight) or
// `pending_acceptance` (a mitra has been offered but hasn't accepted)
// — both are pre-paired states the 5-min timer would terminate.
const [row] = await sql`
SELECT id FROM chat_sessions
WHERE status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_searching_session' })
}
target = row.id
}
if (!target) {
return reply.code(400).send({ error: 'session_id or latest:true required in body' })
}
// Branch targeted vs blast: a chat_session linked to a payment with
// `targeted_mitra_id` is a TARGETED pair waiting for that specific mitra
// to accept (20s countdown). Its expiry must fire RETURNING_CHAT_TIMEOUT
// — which the customer-side TargetedWaitingScreen listens for to surface
// the post-pay BestieOfflinePopup (returning variant). Blast pairs go
// through the regular PAIRING_FAILED → S7 timeout screen path.
const [linked] = await sql`
SELECT ps.targeted_mitra_id
FROM chat_sessions cs
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
WHERE cs.id = ${target}
LIMIT 1
`
if (linked?.targeted_mitra_id) {
await expireTargetedPairingRequest(target)
return { ok: true, session_id: target, kind: 'targeted' }
}
await expirePairingRequest(target, PairingFailureCause.NO_MITRA_AVAILABLE)
return { ok: true, session_id: target, kind: 'blast' }
})
// Force-set the expires_at of an active chat_session to drive Phase 4
// Stage 6 countdown UX (3-min snackbar, last-2-min danger, expired banner)
// without waiting in real time. Reschedules the in-memory session timer so
// `session_warning` / `session_timer` / `session_expired` WS events fire on
// the new schedule.
//
// Body shape:
// { seconds_from_now: 175 } → expire latest active session in N seconds
// { session_id: '<uuid>', seconds_from_now } → expire specific session
fastify.post('/force-session-expires-at', async (request, reply) => {
const { session_id, seconds_from_now } = request.body ?? {}
if (typeof seconds_from_now !== 'number') {
return reply.code(400).send({ error: 'seconds_from_now (number) required' })
}
let target = session_id
if (!target) {
const [row] = await sql`
SELECT id FROM chat_sessions
WHERE status = ${SessionStatus.ACTIVE}
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_active_session' })
}
target = row.id
}
const [updated] = await sql`
UPDATE chat_sessions
SET expires_at = NOW() + (${seconds_from_now} || ' seconds')::interval
WHERE id = ${target} AND status = ${SessionStatus.ACTIVE}
RETURNING id, expires_at
`
if (!updated) {
return reply.code(404).send({ error: 'no_active_session_for_id', session_id: target })
}
// Allow the 3-min warning to fire again on the new schedule.
_resetThreeMinFiredForTest(updated.id)
startSessionTimer(updated.id, updated.expires_at)
// Push an immediate WS resync so the customer UI's local ticker tracks
// the new schedule without waiting for the next scheduled event.
_broadcastTimerResyncForTest(updated.id, updated.expires_at)
return { ok: true, session_id: updated.id, expires_at: updated.expires_at }
})
// Seed a completed chat_sessions row for the customer linked to `phone`,
// pairing them with the most-recent online mitra. Used by Maestro Stage 8
// flow (08_returning_targeted.yaml) so the bestie history list isn't empty.
//
// Body shape:
// { phone: '+62...' } — the customer; mitra is auto-picked.
fastify.post('/seed-history-session', async (request, reply) => {
const phone = request.body?.phone
if (!phone) {
return reply.code(400).send({ error: 'phone required in body' })
}
const [customer] = await sql`
SELECT id FROM customers WHERE phone = ${phone} LIMIT 1
`
if (!customer) {
return reply.code(404).send({ error: 'no_customer_for_phone', phone })
}
const [mitra] = await sql`
SELECT m.id, m.display_name FROM mitras m
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
WHERE s.is_online = true
ORDER BY s.last_heartbeat_at DESC NULLS LAST
LIMIT 1
`
if (!mitra) {
return reply.code(404).send({ error: 'no_online_mitra' })
}
const [session] = await sql`
INSERT INTO chat_sessions (
customer_id, mitra_id, status, topic_sensitivity, topics,
created_at, paired_at, ended_at, duration_minutes, price
) VALUES (
${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 'regular',
${sql.array(['hubungan'])},
NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day',
NOW() - INTERVAL '1 day' + INTERVAL '15 minutes',
15, 30000
)
RETURNING id
`
return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name }
})
// Seed a payment_sessions row in `pending` status for the customer linked
// to `phone`, with expires_at safely in the future. Used by Maestro Stage
// 10 flow (09_chat_tab.yaml) to populate the Pembayaran sub-tab without
// walking the multi-screen S6 paywall → method → duration → method flow.
//
// Body: { phone, isExtension?, amount?, durationMinutes?, mode? }
// - isExtension: defaults false (initial-session payment)
// - amount: defaults 5000 IDR
// - durationMinutes: defaults 15
// - mode: 'chat' (default) | 'call'
fastify.post('/seed-pending-payment', async (request, reply) => {
const phone = request.body?.phone
if (!phone) {
return reply.code(400).send({ error: 'phone required in body' })
}
const isExtension = request.body?.isExtension === true
const amount = Number.isFinite(request.body?.amount) ? request.body.amount : 5000
const durationMinutes = Number.isFinite(request.body?.durationMinutes)
? request.body.durationMinutes
: 15
const mode = request.body?.mode === 'call' ? 'call' : 'chat'
const [customer] = await sql`
SELECT id FROM customers WHERE phone = ${phone} LIMIT 1
`
if (!customer) {
return reply.code(404).send({ error: 'no_customer_for_phone', phone })
}
const [row] = await sql`
INSERT INTO payment_sessions (
customer_id, amount, duration_minutes, is_first_session_discount,
is_extension, status, mode, expires_at
) VALUES (
${customer.id}, ${amount}, ${durationMinutes}, false,
${isExtension}, 'pending', ${mode}, NOW() + INTERVAL '20 minutes'
)
RETURNING id, customer_id, amount, is_extension, status, expires_at
`
return { ok: true, payment_id: row.id, ...row }
})
// Upsert a customer row with phone + display_name (is_anonymous=false).
// Used by Maestro TS-07 to set up the "returning user already has a name"
// precondition: a real returning OTP sign-in must skip the set-name screen
// because resolveCustomerForIdentity returns the existing row unchanged.
//
// Body: { phone, display_name }
fastify.post('/seed-customer', async (request, reply) => {
const phone = request.body?.phone
const display_name = request.body?.display_name
if (!phone || !display_name) {
return reply.code(400).send({ error: 'phone and display_name required in body' })
}
const [row] = await sql`
INSERT INTO customers (phone, display_name, is_anonymous)
VALUES (${phone}, ${display_name}, false)
ON CONFLICT (phone) DO UPDATE
SET display_name = EXCLUDED.display_name,
is_anonymous = false
RETURNING id, phone, display_name, is_anonymous
`
return { ok: true, ...row }
})
// Upsert a mitra with the given phone and is_active flag. Used by the
// mitra_app pre-home Maestro flows to ensure a known mitra exists (with
// the right active state) before driving the OTP verify path. Idempotent —
// safe to run on every test pass.
fastify.post('/seed-mitra', async (request, reply) => {
const phone = request.body?.phone
const display_name = request.body?.display_name
const is_active = request.body?.is_active !== false // default true
if (!phone || !display_name) {
return reply.code(400).send({ error: 'phone and display_name required in body' })
}
const [row] = await sql`
INSERT INTO mitras (phone, display_name, is_active)
VALUES (${phone}, ${display_name}, ${is_active})
ON CONFLICT (phone) DO UPDATE
SET display_name = EXCLUDED.display_name,
is_active = EXCLUDED.is_active
RETURNING id, phone, display_name, is_active
`
return { ok: true, ...row }
})
// Mark EVERY mitra row online. Used by Maestro flows as a setup step to
// ensure a clean known-good state regardless of what previous tests did
// (e.g. force-mitra-offline leaving the dev DB with no online mitras).
// Inserts a status row for every mitra that doesn't have one yet.
fastify.post('/reset-all-mitras-online', async (_request, reply) => {
await sql`
INSERT INTO mitra_online_status
(mitra_id, is_online, last_online_at, last_heartbeat_at)
SELECT id, true, NOW(), NOW() FROM mitras
ON CONFLICT (mitra_id) DO UPDATE SET
is_online = true,
last_online_at = NOW(),
last_heartbeat_at = NOW()
`
const [count] = await sql`
SELECT COUNT(*)::int AS n FROM mitra_online_status WHERE is_online = true
`
return { ok: true, online_count: count.n }
})
// Force a mitra ONLINE in mitra_online_status — used by Maestro flows that
// need a SECOND online mitra (TS-02, TS-06) when the dev DB only has one
// signed-in mitra. With `exclude_mitra_id`, picks any other mitra (offline
// or never-online) and upserts their status row as online. Without it,
// either targets the explicit `mitra_id` or the first offline candidate.
//
// Body: { mitra_id?: uuid, exclude_mitra_id?: uuid }
fastify.post('/force-mitra-online', async (request, reply) => {
const { mitra_id, exclude_mitra_id } = request.body ?? {}
let target = mitra_id
if (!target) {
// Pick any mitra other than exclude — already-online is fine (the
// UPSERT below is idempotent). The intent is "ensure a different
// mitra IS online", not "force a state change".
const [row] = await sql`
SELECT m.id, m.display_name FROM mitras m
WHERE (${exclude_mitra_id ?? null}::uuid IS NULL
OR m.id != ${exclude_mitra_id ?? null}::uuid)
ORDER BY m.id
LIMIT 1
`
if (!row) {
return reply.code(404).send({
error: 'no_other_mitra_available',
exclude_mitra_id: exclude_mitra_id ?? null,
})
}
target = row.id
}
const [updated] = await sql`
INSERT INTO mitra_online_status
(mitra_id, is_online, last_online_at, last_heartbeat_at)
VALUES (${target}, true, NOW(), NOW())
ON CONFLICT (mitra_id) DO UPDATE SET
is_online = true,
last_online_at = NOW(),
last_heartbeat_at = NOW()
RETURNING mitra_id, is_online, last_heartbeat_at
`
return { ok: true, ...updated }
})
// Force a specific mitra OFFLINE in mitra_online_status — used by Maestro
// flows (TS-02 / TS-03 in requirement/phase4-customer-flow.md) that need the
// customer's history-list bestie row to render in its offline (dimmed) state.
// Distinct from the CC `mitra-online-status/:id/offline` endpoint which
// requires a CC_JWT; this one is unauthenticated (NODE_ENV-gated) so flows
// don't need CC credentials.
//
// Body: { mitra_id }
fastify.post('/force-mitra-offline', async (request, reply) => {
const mitraId = request.body?.mitra_id
if (!mitraId) {
return reply.code(400).send({ error: 'mitra_id required in body' })
}
const [updated] = await sql`
UPDATE mitra_online_status
SET is_online = false,
last_heartbeat_at = NOW() - INTERVAL '10 minutes'
WHERE mitra_id = ${mitraId}
RETURNING mitra_id, is_online, last_heartbeat_at
`
if (!updated) {
return reply.code(404).send({ error: 'no_online_status_row', mitra_id: mitraId })
}
return { ok: true, ...updated }
})
// Delete the mitra_online_status row for a given mitra — used by Maestro
// scenario flows that need to simulate a "freshly created mitra with NO
// status row yet" (the natural state right after seed_mitra and before
// any /api/mitra/status call from the app). The app's first /status call
// re-creates the row via ensureStatusRow() with the DB default
// is_online=false; this endpoint just rewinds to that pre-state.
//
// Body: { mitra_id }
fastify.post('/delete-mitra-status-row', async (request, reply) => {
const mitraId = request.body?.mitra_id
if (!mitraId) {
return reply.code(400).send({ error: 'mitra_id required in body' })
}
const result = await sql`
DELETE FROM mitra_online_status WHERE mitra_id = ${mitraId}
RETURNING mitra_id
`
return { ok: true, mitra_id: mitraId, deleted: result.length > 0 }
})
// Accept the most recent pending pairing notification, regardless of which
// mitra it was sent to. Used by Maestro flows where the test doesn't know
// (or care) which specific mitra should accept — e.g. TS-02 (blast where
// the seeded mitra was forced offline, so an unknown OTHER online mitra
// got the notification). No body required.
fastify.post('/accept-latest-pending', async (_request, reply) => {
const [notif] = await sql`
SELECT n.session_id, n.mitra_id
FROM chat_request_notifications n
JOIN chat_sessions s ON s.id = n.session_id
WHERE s.status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
AND n.response IS NULL
ORDER BY n.notified_at DESC
LIMIT 1
`
if (!notif) {
return reply.code(404).send({ error: 'no_pending_notification' })
}
const session = await acceptPairingRequest(notif.session_id, notif.mitra_id)
return { ok: true, session_id: notif.session_id, mitra_id: notif.mitra_id, session }
})
// Accept the most recent pending pairing request for a given mitra without
// needing a mitra JWT. Used by Maestro flows that drive the customer side
// through to the post-payment waiting screen and need the mitra side to
// "accept" so the customer transitions onward (see TS-01 in
// requirement/phase4-customer-flow.md).
//
// Body: { mitra_id }
fastify.post('/mitra-accept-latest', async (request, reply) => {
const mitraId = request.body?.mitra_id
if (!mitraId) {
return reply.code(400).send({ error: 'mitra_id required in body' })
}
const pending = await getPendingRequestsForMitra(mitraId)
if (!pending || pending.length === 0) {
return reply.code(404).send({ error: 'no_pending_request', mitra_id: mitraId })
}
// Newest first — flows always want the request that was just created.
const latest = pending[pending.length - 1]
const session = await acceptPairingRequest(latest.session_id, mitraId)
return { ok: true, session_id: latest.session_id, session }
})
// Test-only: read the in-memory websocket connection state for a session.
// Used by Maestro flows asserting that backgrounding the customer/mitra
// app closed its WebSocket (which is what gates FCM fallback per
// chat.service.js:51). Returns booleans per role.
fastify.get('/ws-connection-state', async (request, reply) => {
const sessionId = request.query?.session_id
if (!sessionId) {
return reply.code(400).send({ error: 'session_id query param required' })
}
return inspectSessionWsState(sessionId)
})
// Test-only: emulate an FCM push to a specific customer without going
// through the chat-session/WS-fallback path. Useful for poking the
// device's local-notification handler in isolation (e.g. verifying the
// `chat_messages` channel renders, FCM token validity, etc).
//
// Body: { customer_id?, latest_customer?, content? }
// - customer_id: target a specific customers.id.
// - latest_customer: true → pick the most-recently-created customer
// that has an fcm_token (handy when the maestro flow just signed in
// anonymously and you don't have the UUID).
// - content: optional override for the notification body text.
fastify.post('/send-fcm-chat-message', async (request, reply) => {
const { customer_id: customerId, latest_customer: latest, content } =
request.body ?? {}
let targetId = customerId
if (!targetId && latest === true) {
const [row] = await sql`
SELECT id FROM customers
WHERE fcm_token IS NOT NULL
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_customer_with_fcm_token' })
}
targetId = row.id
}
if (!targetId) {
return reply.code(400).send({
error: 'customer_id or latest_customer:true required in body',
})
}
const body = content || 'Pesan baru dari bestie · ketuk buat balas'
const ok = await sendPushNotification(UserType.CUSTOMER, targetId, {
title: 'Pesan baru dari Bestie',
body,
data: { type: 'chat_message', session_id: 'test-emulated' },
})
return { ok, customer_id: targetId, body }
})
// Test-only: send a chat message AS the customer of a paired session.
// Mirrors send-chat-message-as-mitra but with senderType=CUSTOMER —
// useful for asserting the mitra-side FCM fallback when the mitra app is
// backgrounded. Returns the dispatch transport so the caller can assert
// delivered_via=fcm.
//
// Body: { session_id, content }
fastify.post('/send-chat-message-as-customer', async (request, reply) => {
const { session_id: sessionId, content } = request.body ?? {}
if (!sessionId || !content) {
return reply.code(400).send({ error: 'session_id and content required in body' })
}
const [session] = await sql`
SELECT customer_id FROM chat_sessions WHERE id = ${sessionId} LIMIT 1
`
if (!session?.customer_id) {
return reply.code(404).send({ error: 'no_session_or_customer', session_id: sessionId })
}
// Recipient here is the mitra — inspect its WS state before dispatch so
// we can answer "websocket" vs "fcm" honestly.
const wsBefore = inspectSessionWsState(sessionId)
const message = await sendMessage({
sessionId,
senderType: UserType.CUSTOMER,
senderId: session.customer_id,
content,
})
return {
ok: true,
message_id: message.id,
delivered_via: wsBefore.mitra_connected ? 'websocket' : 'fcm',
}
})
// Test-only: send a chat message AS the mitra of a paired session, using
// the real chat.service.sendMessage code path. Returns which transport
// actually carried the message — useful for asserting the WS-vs-FCM
// fallback (e.g. Maestro backgrounds the customer app, calls this, and
// expects `delivered_via: "fcm"`).
//
// Body: { session_id, content }
fastify.post('/send-chat-message-as-mitra', async (request, reply) => {
const { session_id: sessionId, content } = request.body ?? {}
if (!sessionId || !content) {
return reply.code(400).send({ error: 'session_id and content required in body' })
}
const [session] = await sql`
SELECT mitra_id FROM chat_sessions WHERE id = ${sessionId} LIMIT 1
`
if (!session?.mitra_id) {
return reply.code(404).send({ error: 'no_session_or_mitra', session_id: sessionId })
}
// Snapshot WS state BEFORE the send so we can answer "which path?"
// honestly: sendMessage tries WS first, falls back to FCM only when WS
// returned false. We inspect customer_connected here because the mitra
// is the sender — recipient is the customer.
const wsBefore = inspectSessionWsState(sessionId)
const message = await sendMessage({
sessionId,
senderType: UserType.MITRA,
senderId: session.mitra_id,
content,
})
return {
ok: true,
message_id: message.id,
delivered_via: wsBefore.customer_connected ? 'websocket' : 'fcm',
}
})
}