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>
632 lines
26 KiB
JavaScript
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',
|
|
}
|
|
})
|
|
}
|