Phase 4 §4: payment-before-pair for returning users + Maestro suite
Stages 5.1, 5.3, 5.4 of the returning-user flow rework. All three §4 entry paths now require payment BEFORE pairing, matching the updated mermaid spec. * Spec (requirement/flow_customer.mermaid.md §4): payment block converges three call-sites (bestie-yang-udah-kenal-online, bestie-baru, offline-popup → cari bestie lain). PairRoute dispatches lama → targeted pair, baru/cari-lain → §3 blast. §3 retains its post-payment-shared contract. * Stage 5.1 (client_app): PaymentDraft carries targetedMitraId + topicSensitivity. bestie_history_list seeds the draft + pushes /payment/entry (was legacy /payment). searching_screen branches on draft.targetedMitraId for blast-vs-targeted dispatch. payment_entry uses resetExceptTarget(); bestie_choice_sheet + home _onCurhatBestieBaruPressed call explicit reset() before push so the keepAlive draft can't leak stale targeting into a blast. * Stage 5.3 (client_app): new BestieOfflineVariant.prePayReturning. Bestie-history-list _BestieRow splits tappable from dim so offline rows render dimmed but route taps into the popup. CTA "cari bestie lain" resets the draft + pushes /payment/entry. * Stage 5.4 (client_app): deleted legacy /payment route, payment_screen.dart, payment_notifier.dart(+.g.dart). router cleaned. * Tests (requirement/phase4-customer-flow.md + client_app/.maestro/): six Maestro flows TS-01..TS-06 covering every §4 branching point, all passing end-to-end. Shared onboarding prelude under .maestro/subflows/. New helper scripts: accept_latest_pending, force_mitra_offline, force_other_mitra_online, reset_all_mitras_online, mitra_accept_latest_internal. New backend _test endpoints to match. /reset-phone now cascade-deletes customer_transactions (FK was blocking). /force-pairing-timeout branches targeted (RETURNING_CHAT_TIMEOUT via expireTargetedPairingRequest, now exported) vs blast (PAIRING_FAILED). seed_history_session also outputs MITRA_NAME_RE (regex-escaped) for reliable selectors against display names containing regex specials. * mitra_app: dispose-during-deactivate guardrail for back-press on the mitra chat screen after the customer's goodbye message. Pending real emulator repro verification (carried over from 2026-05-15). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
// test phone numbers or fixed codes into production code paths.
|
||||
|
||||
import { peekStubOtp } from '../../services/otp.service.js'
|
||||
import { expirePairingRequest } from '../../services/pairing.service.js'
|
||||
import { acceptPairingRequest, expirePairingRequest, expireTargetedPairingRequest, getPendingRequestsForMitra } from '../../services/pairing.service.js'
|
||||
import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
import { PairingFailureCause, SessionStatus } from '../../constants.js'
|
||||
@@ -42,6 +42,8 @@ export const internalTestRoutes = async (fastify) => {
|
||||
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'`
|
||||
@@ -154,8 +156,25 @@ export const internalTestRoutes = async (fastify) => {
|
||||
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 }
|
||||
return { ok: true, session_id: target, kind: 'blast' }
|
||||
})
|
||||
|
||||
// Force-set the expires_at of an active chat_session to drive Phase 4
|
||||
@@ -286,4 +305,136 @@ export const internalTestRoutes = async (fastify) => {
|
||||
`
|
||||
return { ok: true, payment_id: row.id, ...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 }
|
||||
})
|
||||
|
||||
// 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 }
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user