Phase 3.7: paid pairing flow + returning chat + extension flip

- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

View File

@@ -1,9 +1,40 @@
import { getDb } from '../db/client.js'
import { SessionStatus } from '../constants.js'
import { getMitraPingConfig } from './config.service.js'
import { getMitraPingConfig, getMaxCustomersPerMitra } from './config.service.js'
import { subscribe } from '../plugins/valkey.js'
const sql = getDb()
// --- Short-TTL availability cache for the 5s-poll endpoint ---
// In-memory snapshot { available, count, expiresAt }. The cache:
// - is recomputed at most once per AVAILABILITY_TTL_MS (10s backstop)
// - is invalidated explicitly when CC changes max_customers_per_mitra (call invalidateAvailabilityCache())
// This keeps customer polls off the DB hot path while staying close to real time.
const AVAILABILITY_TTL_MS = 10_000
let availabilityCache = null // { available, count, expiresAt }
export const invalidateAvailabilityCache = () => {
availabilityCache = null
}
// Subscribe once at module load so other-instance config updates also bust this cache.
// Single-instance: the local mutator already invalidates, so this is a no-op extra.
let _subscribed = false
const ensureSubscribed = () => {
if (_subscribed) return
_subscribed = true
try {
subscribe('config:invalidate', (msg) => {
if (msg?.key === 'max_customers_per_mitra') {
invalidateAvailabilityCache()
}
})
} catch (_) {
// Valkey may not be reachable in some test contexts; non-fatal.
}
}
ensureSubscribed()
export const ensureStatusRow = async (mitraId) => {
await sql`
INSERT INTO mitra_online_status (mitra_id)
@@ -23,6 +54,7 @@ export const setOnline = async (mitraId) => {
await sql`
INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${mitraId}, 'online')
`
invalidateAvailabilityCache()
}
export const setOffline = async (mitraId) => {
@@ -41,6 +73,7 @@ export const setOffline = async (mitraId) => {
await sql`
INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${mitraId}, 'offline')
`
invalidateAvailabilityCache()
}
export const heartbeat = async (mitraId) => {
@@ -116,5 +149,93 @@ export const autoOfflineStaleMitras = async () => {
`
}
// Capacity may have changed (mitra went offline) — invalidate the customer-facing
// availability cache so the next poll reflects reality.
if (stale.length > 0) invalidateAvailabilityCache()
return stale.length
}
/**
* Customer-home availability check, cached in-memory for AVAILABILITY_TTL_MS.
*
* Returns { available, count } where:
* - available = true iff at least one mitra is online AND below max_customers_per_mitra
* - count is the number of qualifying mitras (CC/debug only — never expose to customer UI)
*
* The 5s-poll endpoint backed by this function MUST NOT issue per-poll DB queries.
* The 10s TTL caps DB load to ~6 queries/min total regardless of poller count.
*
* Note: today the source of truth for online status + active session counts is Postgres
* (mitra_online_status + chat_sessions). A future refactor can mirror these into Valkey
* sets/hashes (matching the existing memory item "Session Timer Scaling"); the contract
* of this function — Valkey/cache reads only on the hot path — stays the same.
*/
export const countAvailableMitrasFromCache = async () => {
const now = Date.now()
if (availabilityCache && availabilityCache.expiresAt > now) {
return { available: availabilityCache.available, count: availabilityCache.count }
}
const { max_customers_per_mitra } = await getMaxCustomersPerMitra()
const [{ count }] = await sql`
SELECT COUNT(*)::int AS count
FROM mitras m
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
WHERE m.is_active = true
AND s.is_online = true
AND (
SELECT COUNT(*) FROM chat_sessions cs
WHERE cs.mitra_id = m.id
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})
) < ${max_customers_per_mitra}
`
const available = count > 0
availabilityCache = {
available,
count,
expiresAt: now + AVAILABILITY_TTL_MS,
}
return { available, count }
}
/**
* Mitra-online check for use during pairing/extension safeguards.
* Combines the Valkey-mirrored online flag (Postgres mitra_online_status today) with
* the WebSocket-connected check. Never use "in-session" as a proxy for "online".
*/
export const isMitraReachable = async (mitraId) => {
const [row] = await sql`
SELECT is_online FROM mitra_online_status WHERE mitra_id = ${mitraId}
`
return Boolean(row?.is_online)
}
/**
* Returns active session count for a mitra (sessions that count toward max_customers_per_mitra).
*/
export const getMitraActiveSessionCount = async (mitraId) => {
const [{ count }] = await sql`
SELECT COUNT(*)::int AS count FROM chat_sessions
WHERE mitra_id = ${mitraId}
AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})
`
return count
}
/**
* True iff this mitra is currently in an ACTIVE chat with this specific customer.
* Used by targeted "Curhat lagi" pre-check: a mitra at-capacity but mid-session
* with the requesting customer is still allowed to receive a returning-chat card.
*/
export const isMitraInActiveSessionWithCustomer = async (mitraId, customerId) => {
const [row] = await sql`
SELECT id FROM chat_sessions
WHERE mitra_id = ${mitraId}
AND customer_id = ${customerId}
AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING})
LIMIT 1
`
return Boolean(row)
}