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:
2026-05-17 20:25:15 +08:00
parent 1c9d81d81d
commit e09f76ceb6
32 changed files with 1755 additions and 680 deletions

View File

@@ -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 }
})
}

View File

@@ -742,7 +742,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
* - cause_tag is targeted_mitra_timeout (audit row only)
* - WS event sent to customer is RETURNING_CHAT_TIMEOUT (not PAIRING_FAILED)
*/
const expireTargetedPairingRequest = async (sessionId) => {
export const expireTargetedPairingRequest = async (sessionId) => {
const [session] = await sql`
UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED}