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

@@ -7,6 +7,7 @@ import SessionsPage from './pages/sessions/SessionsPage'
import UsersPage from './pages/users/UsersPage'
import SettingsPage from './pages/settings/SettingsPage'
import MitraActivityPage from './pages/mitra-activity/MitraActivityPage'
import FailedPairingsPage from './pages/failed-pairings/FailedPairingsPage'
import Layout from './components/Layout'
const ProtectedRoute = ({ children }) => {
@@ -24,6 +25,7 @@ export default function App() {
<Route path="dashboard" element={<DashboardPage />} />
<Route path="mitras" element={<MitrasPage />} />
<Route path="sessions" element={<SessionsPage />} />
<Route path="failed-pairings" element={<FailedPairingsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="mitra-activity" element={<MitraActivityPage />} />

View File

@@ -63,6 +63,7 @@ export default function Layout() {
<li><NavLink to="/dashboard">Dashboard</NavLink></li>
<li><NavLink to="/mitras">Mitra</NavLink></li>
<li><NavLink to="/sessions">Sesi</NavLink></li>
<li><NavLink to="/failed-pairings">Failed Pairings</NavLink></li>
<li><NavLink to="/users">Users</NavLink></li>
<li><NavLink to="/mitra-activity">Aktivitas Mitra</NavLink></li>
<li><NavLink to="/settings">Settings</NavLink></li>

View File

@@ -0,0 +1,46 @@
// Frontend mirror of selected backend enums (backend/src/constants.js).
// Keep in sync when new values are added on the server.
// Pairing failure cause tags — used by the Failed Pairings screen filter.
export const PairingFailureCause = Object.freeze({
NO_MITRA_AVAILABLE: 'no_mitra_available',
ALL_MITRAS_REJECTED: 'all_mitras_rejected',
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
PAYMENT_SESSION_EXPIRED: 'payment_session_expired',
CUSTOMER_CANCELLED: 'customer_cancelled',
EXTENSION_REJECTED: 'extension_rejected',
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped',
})
export const PairingFailureCauseLabel = Object.freeze({
[PairingFailureCause.NO_MITRA_AVAILABLE]: 'No mitra available',
[PairingFailureCause.ALL_MITRAS_REJECTED]: 'All mitras rejected',
[PairingFailureCause.TARGETED_MITRA_OFFLINE]: 'Targeted mitra offline',
[PairingFailureCause.TARGETED_MITRA_REJECTED]: 'Targeted mitra rejected',
[PairingFailureCause.TARGETED_MITRA_TIMEOUT]: 'Targeted mitra timeout',
[PairingFailureCause.PAYMENT_SESSION_EXPIRED]: 'Payment session expired',
[PairingFailureCause.CUSTOMER_CANCELLED]: 'Customer cancelled',
[PairingFailureCause.EXTENSION_REJECTED]: 'Extension rejected',
[PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED]: 'Extension safeguard tripped',
})
// Operator actions on a failed-pairing row.
export const PairingFailureOperatorAction = Object.freeze({
REFUNDED: 'refunded',
CREDITED: 'credited',
NO_ACTION: 'no_action',
})
export const PairingFailureOperatorActionLabel = Object.freeze({
[PairingFailureOperatorAction.REFUNDED]: 'Refunded',
[PairingFailureOperatorAction.CREDITED]: 'Credited',
[PairingFailureOperatorAction.NO_ACTION]: 'No Action',
})
// Default action when the mitra fails to respond to an extension request in time.
export const ExtensionTimeoutAction = Object.freeze({
AUTO_APPROVE: 'auto_approve',
AUTO_REJECT: 'auto_reject',
})

View File

@@ -0,0 +1,257 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
import {
PairingFailureCause,
PairingFailureCauseLabel,
PairingFailureOperatorAction,
PairingFailureOperatorActionLabel,
} from '../../core/constants'
const PAGE_SIZE = 50
const CAUSE_OPTIONS = Object.values(PairingFailureCause).map((value) => ({
value,
label: PairingFailureCauseLabel[value],
}))
const fetchFailedPairings = async ({ causeTags, dateFrom, dateTo, limit, offset }) => {
const params = new URLSearchParams()
for (const tag of causeTags) params.append('cause_tags', tag)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
params.set('limit', String(limit))
params.set('offset', String(offset))
const res = await apiClient.get(`/internal/failed-pairings?${params}`)
return res.data.data
}
const submitOperatorAction = async ({ id, action }) => {
const res = await apiClient.post(`/internal/failed-pairings/${id}/action`, { action })
return res.data.data
}
const formatRupiah = (amount) => {
if (amount === null || amount === undefined) return '-'
return `Rp ${Number(amount).toLocaleString('id-ID')}`
}
const formatDateTime = (iso) => {
if (!iso) return '-'
return new Date(iso).toLocaleString('id-ID')
}
const operatorActionLabel = (row) => {
if (!row.operator_action) return '-'
return PairingFailureOperatorActionLabel[row.operator_action] ?? row.operator_action
}
export default function FailedPairingsPage() {
const queryClient = useQueryClient()
const [selectedCauses, setSelectedCauses] = useState([])
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [page, setPage] = useState(1)
const [openMenuId, setOpenMenuId] = useState(null)
const offset = (page - 1) * PAGE_SIZE
const { data, isLoading, isError } = useQuery({
queryKey: ['failed-pairings', selectedCauses, dateFrom, dateTo, page],
queryFn: () => fetchFailedPairings({
causeTags: selectedCauses,
dateFrom: dateFrom || null,
dateTo: dateTo || null,
limit: PAGE_SIZE,
offset,
}),
keepPreviousData: true,
})
const actionMutation = useMutation({
mutationFn: submitOperatorAction,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['failed-pairings'] })
setOpenMenuId(null)
},
})
const toggleCause = (value) => {
setPage(1)
setSelectedCauses((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
)
}
const clearFilters = () => {
setSelectedCauses([])
setDateFrom('')
setDateTo('')
setPage(1)
}
const total = data?.total ?? 0
const rows = data?.rows ?? []
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div>
<h1>Failed Pairings</h1>
<div style={{ marginBottom: 16, padding: 12, border: '1px solid #eee', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<strong style={{ marginRight: 8 }}>Cause:</strong>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 4 }}>
{CAUSE_OPTIONS.map((opt) => (
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13 }}>
<input
type="checkbox"
checked={selectedCauses.includes(opt.value)}
onChange={() => toggleCause(opt.value)}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ fontSize: 13 }}>From:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
/>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ fontSize: 13 }}>To:</label>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
/>
</div>
<button onClick={clearFilters} style={{ fontSize: 12 }}>Clear filters</button>
</div>
</div>
{isLoading && <div>Loading...</div>}
{isError && <p style={{ color: 'red' }}>Gagal memuat data failed pairings.</p>}
{!isLoading && !isError && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Created</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Targeted Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Cause</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Amount</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Operator Action</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned By</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned At</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={9} style={{ padding: 24, textAlign: 'center', color: '#666' }}>
Belum ada data failed pairings.
</td>
</tr>
)}
{rows.map((row) => {
const canAction = !row.operator_action
return (
<tr key={row.id}>
<td style={{ padding: 8 }}>{formatDateTime(row.created_at)}</td>
<td style={{ padding: 8 }}>{row.customer_call_name ?? '-'}</td>
<td style={{ padding: 8 }}>{row.targeted_mitra_call_name ?? '-'}</td>
<td style={{ padding: 8 }}>
{PairingFailureCauseLabel[row.cause_tag] ?? row.cause_tag}
</td>
<td style={{ padding: 8 }}>{formatRupiah(row.amount)}</td>
<td style={{ padding: 8 }}>{operatorActionLabel(row)}</td>
<td style={{ padding: 8 }}>{row.actioned_by_name ?? '-'}</td>
<td style={{ padding: 8 }}>{formatDateTime(row.actioned_at)}</td>
<td style={{ padding: 8, position: 'relative' }}>
{canAction ? (
<>
<button
onClick={() => setOpenMenuId(openMenuId === row.id ? null : row.id)}
disabled={actionMutation.isPending}
style={{ fontSize: 12 }}
>
Action
</button>
{openMenuId === row.id && (
<div style={{
position: 'absolute',
right: 8,
top: '100%',
background: 'white',
border: '1px solid #ddd',
boxShadow: '0 2px 6px rgba(0,0,0,0.08)',
zIndex: 10,
minWidth: 180,
}}>
<button
style={menuItemStyle}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.REFUNDED })}
>
Mark as refunded
</button>
<button
style={menuItemStyle}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.CREDITED })}
>
Mark as credited
</button>
<button
style={menuItemStyle}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.NO_ACTION })}
>
Mark as no-action
</button>
</div>
)}
</>
) : (
<span style={{ color: '#999', fontSize: 12 }}></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center', alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Prev</button>
<span>Page {page} of {totalPages} ({total} total)</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</>
)}
{actionMutation.isError && (
<p style={{ color: 'red', marginTop: 8 }}>Gagal menyimpan operator action.</p>
)}
</div>
)
}
const menuItemStyle = {
display: 'block',
width: '100%',
padding: '8px 12px',
background: 'white',
border: 'none',
borderBottom: '1px solid #f0f0f0',
textAlign: 'left',
cursor: 'pointer',
fontSize: 13,
}

View File

@@ -49,12 +49,12 @@ export default function LoginPage() {
<h2>Control Center</h2>
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
<label htmlFor="cc-login-email">Email</label>
<input id="cc-login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
</div>
<div>
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
<label htmlFor="cc-login-password">Password</label>
<input id="cc-login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={loading} style={{ width: '100%' }}>

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
import { ExtensionTimeoutAction } from '../../core/constants'
const fetchAnonymityConfig = async () => {
const res = await apiClient.get('/internal/config/anonymity')
@@ -74,6 +75,47 @@ const updateSensitivityConfig = async (data) => {
return res.data.data
}
// Paid pairing flow + extension flip
const fetchPairingBlastTimeout = async () => {
const res = await apiClient.get('/internal/config/pairing-blast-timeout')
return res.data.data
}
const updatePairingBlastTimeout = async (pairing_blast_timeout_seconds) => {
const res = await apiClient.patch('/internal/config/pairing-blast-timeout', { pairing_blast_timeout_seconds })
return res.data.data
}
const fetchPaymentSessionTimeout = async () => {
const res = await apiClient.get('/internal/config/payment-session-timeout')
return res.data.data
}
const updatePaymentSessionTimeout = async (payment_session_timeout_minutes) => {
const res = await apiClient.patch('/internal/config/payment-session-timeout', { payment_session_timeout_minutes })
return res.data.data
}
const fetchReturningChatTimeout = async () => {
const res = await apiClient.get('/internal/config/returning-chat-timeout')
return res.data.data
}
const updateReturningChatTimeout = async (returning_chat_confirmation_timeout_seconds) => {
const res = await apiClient.patch('/internal/config/returning-chat-timeout', { returning_chat_confirmation_timeout_seconds })
return res.data.data
}
const fetchExtensionDefaultAction = async () => {
const res = await apiClient.get('/internal/config/extension-default-action')
return res.data.data
}
const updateExtensionDefaultAction = async (extension_default_action_on_timeout) => {
const res = await apiClient.patch('/internal/config/extension-default-action', { extension_default_action_on_timeout })
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
@@ -143,7 +185,50 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-sensitivity'] }),
})
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading) return <div>Loading...</div>
// Pairing Blast Timeout
const { data: pbtData, isLoading: pbtLoading } = useQuery({
queryKey: ['config-pairing-blast-timeout'],
queryFn: fetchPairingBlastTimeout,
})
const pbtMutation = useMutation({
mutationFn: updatePairingBlastTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-pairing-blast-timeout'] }),
})
// Payment Session Timeout
const { data: pstData, isLoading: pstLoading } = useQuery({
queryKey: ['config-payment-session-timeout'],
queryFn: fetchPaymentSessionTimeout,
})
const pstMutation = useMutation({
mutationFn: updatePaymentSessionTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-payment-session-timeout'] }),
})
// Returning Chat Confirmation Timeout
const { data: rctData, isLoading: rctLoading } = useQuery({
queryKey: ['config-returning-chat-timeout'],
queryFn: fetchReturningChatTimeout,
})
const rctMutation = useMutation({
mutationFn: updateReturningChatTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-returning-chat-timeout'] }),
})
// Extension Default Action on Timeout
const { data: edaData, isLoading: edaLoading } = useQuery({
queryKey: ['config-extension-default-action'],
queryFn: fetchExtensionDefaultAction,
})
const edaMutation = useMutation({
mutationFn: updateExtensionDefaultAction,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-default-action'] }),
})
if (
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
pbtLoading || pstLoading || rctLoading || edaLoading
) return <div>Loading...</div>
return (
<div>
@@ -320,6 +405,94 @@ export default function SettingsPage() {
</p>
{senMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Blast Pairing</h2>
<p>Berapa lama backend menunggu mitra menerima sebelum blast dianggap gagal.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="5"
value={pbtData?.pairing_blast_timeout_seconds ?? 60}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 5) pbtMutation.mutate(val)
}}
disabled={pbtMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{pbtMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Sesi Pembayaran</h2>
<p>Sesi pembayaran customer akan kedaluwarsa otomatis setelah durasi ini jika belum dikonfirmasi atau belum menghasilkan chat.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="1"
value={pstData?.payment_session_timeout_minutes ?? 20}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) pstMutation.mutate(val)
}}
disabled={pstMutation.isPending}
style={{ width: 80 }}
/>
<span>menit</span>
</div>
{pstMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Konfirmasi Chat Lanjutan</h2>
<p>Berapa detik bestie punya waktu untuk menerima/menolak permintaan chat lanjutan dari customer sebelum otomatis ditolak.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="5"
value={rctData?.returning_chat_confirmation_timeout_seconds ?? 20}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 5) rctMutation.mutate(val)
}}
disabled={rctMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{rctMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Aksi Default jika Bestie Tidak Menjawab Extension</h2>
<p>Jika bestie tidak merespon permintaan extension dalam batas waktu, sistem akan otomatis menerima atau menolak sesuai pilihan ini.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="radio"
name="extension_default_action_on_timeout"
value={ExtensionTimeoutAction.AUTO_APPROVE}
checked={(edaData?.extension_default_action_on_timeout ?? ExtensionTimeoutAction.AUTO_APPROVE) === ExtensionTimeoutAction.AUTO_APPROVE}
onChange={() => edaMutation.mutate(ExtensionTimeoutAction.AUTO_APPROVE)}
disabled={edaMutation.isPending}
/>
Otomatis disetujui (auto-approve)
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="radio"
name="extension_default_action_on_timeout"
value={ExtensionTimeoutAction.AUTO_REJECT}
checked={edaData?.extension_default_action_on_timeout === ExtensionTimeoutAction.AUTO_REJECT}
onChange={() => edaMutation.mutate(ExtensionTimeoutAction.AUTO_REJECT)}
disabled={edaMutation.isPending}
/>
Otomatis ditolak (auto-reject)
</label>
{edaMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)
}