// 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_requests 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_requests 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_requests 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: '' } → 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_requests 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_requests 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: '' } → 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_requests ps ON ps.id = cs.payment_request_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: '', 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_requests 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_requests ( 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', } }) }