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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user