Phase 3.3: topic sensitivity + Phase 3.4: auth foundation

Phase 3.3 — Session Topic Sensitivity (complete):
- Backend: topic_sensitivity column + session_sensitivity_log, sensitivity service
  (flip with one-way-latch + audit), PATCH /api/shared/chat/sessions/:id/topic,
  topic carried in pairing + extension WS payloads, CC filter + sensitive stats
  + per-mitra sensitive columns on activity page
- client_app: TopicSelectionBottomSheet before pricing, topic flows through
  pairing request, silent WS handler for session_topic_updated
- mitra_app: SensitivityBadge + SensitivityTheme + sensitivityConfigProvider,
  overlay badge + yellow accent, chat screen app-bar toggle with configurable
  confirmation + latch, extension card shows current flag, history + transcript
  yellow theme
- control_center: Sensitivitas Topik settings section, topic filter + column
  with inline audit log, sensitive stats dashboard card, mitra activity
  sensitive columns with QC flag

Phase 3.4 — Self-Managed Auth (foundation only):
- Migration: auth_sessions + otp_requests tables, social identity columns on
  customers, password_hash + lockout on control_center_users, OTP + CC lockout
  app_config keys
- New services: password (bcrypt + complexity), token (JWT HS256 + refresh
  rotation, session_id claim pre-wires future Valkey revocation),
  social-identity (Google + Apple JWKS), OTP (Fazpass stub — real API TBD)
- Constants: AuthProvider + OtpChannel
- Middleware, auth route rewrites, WS auth update, Firebase → FCM isolation
  still pending (next chunk); Fazpass docs + Apple Developer setup still
  required before E2E testing

Docs:
- requirement/phase3.3.md, phase3.3-plan.md, phase3.3-testing.md
- requirement/phase3.4.md, phase3.4-plan.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 10:15:12 +08:00
parent 97d50a8e08
commit 780cade3db
44 changed files with 3834 additions and 103 deletions

View File

@@ -19,7 +19,7 @@ export default function DashboardPage() {
<div>
<h1>Dashboard</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 32 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 32 }}>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#2563eb' }}>{data?.active_chats ?? 0}</div>
<div style={{ color: '#666' }}>Chat Aktif</div>
@@ -32,6 +32,18 @@ export default function DashboardPage() {
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#f59e0b' }}>{data?.pending_requests ?? 0}</div>
<div style={{ color: '#666' }}>Request Pending</div>
</div>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8, background: '#FFF8DF' }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#B88900' }}>
{data?.sensitive?.last_30d_sensitive ?? 0}
<span style={{ fontSize: 16, color: '#666', marginLeft: 8 }}>
({data?.sensitive?.last_30d_percent ?? 0}%)
</span>
</div>
<div style={{ color: '#666' }}>Sesi Sensitif (30 hari)</div>
<div style={{ color: '#888', fontSize: 12, marginTop: 4 }}>
Total semua waktu: {data?.sensitive?.total ?? 0}
</div>
</div>
</div>
<h2>Customer per Mitra</h2>

View File

@@ -102,23 +102,36 @@ export default function MitraActivityPage() {
<th style={{ padding: 8 }}>Ignored</th>
<th style={{ padding: 8 }}>Rate (%)</th>
<th style={{ padding: 8 }}>Avg Response (s)</th>
<th style={{ padding: 8, background: '#FFF8DF' }}>Sensitif Total</th>
<th style={{ padding: 8, background: '#FFF8DF' }}>Sensitif Diterima</th>
<th style={{ padding: 8, background: '#FFF8DF' }}>Sensitif Rate (%)</th>
</tr>
</thead>
<tbody>
{(summary || []).map(s => (
<tr key={s.mitra_id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{s.mitra_display_name}</td>
<td style={{ padding: 8 }}>{s.total_requests}</td>
<td style={{ padding: 8, color: '#22c55e' }}>{s.accepted_count}</td>
<td style={{ padding: 8, color: '#ef4444' }}>{s.rejected_count}</td>
<td style={{ padding: 8, color: '#f97316' }}>{s.missed_count}</td>
<td style={{ padding: 8, color: '#9ca3af' }}>{s.ignored_count}</td>
<td style={{ padding: 8 }}>{s.acceptance_rate ?? '-'}%</td>
<td style={{ padding: 8 }}>{s.avg_response_time_seconds ?? '-'}</td>
</tr>
))}
{(summary || []).map(s => {
const overall = s.acceptance_rate != null ? Number(s.acceptance_rate) : null
const sensRate = s.sensitive_acceptance_rate != null ? Number(s.sensitive_acceptance_rate) : null
const flagSensRate = overall != null && sensRate != null && (overall - sensRate) >= 20
return (
<tr key={s.mitra_id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{s.mitra_display_name}</td>
<td style={{ padding: 8 }}>{s.total_requests}</td>
<td style={{ padding: 8, color: '#22c55e' }}>{s.accepted_count}</td>
<td style={{ padding: 8, color: '#ef4444' }}>{s.rejected_count}</td>
<td style={{ padding: 8, color: '#f97316' }}>{s.missed_count}</td>
<td style={{ padding: 8, color: '#9ca3af' }}>{s.ignored_count}</td>
<td style={{ padding: 8 }}>{s.acceptance_rate ?? '-'}%</td>
<td style={{ padding: 8 }}>{s.avg_response_time_seconds ?? '-'}</td>
<td style={{ padding: 8 }}>{s.sensitive_total || 0}</td>
<td style={{ padding: 8 }}>{s.sensitive_accepted || 0}</td>
<td style={{ padding: 8, color: flagSensRate ? '#ef4444' : undefined, fontWeight: flagSensRate ? 'bold' : undefined }}>
{(s.sensitive_total || 0) === 0 ? '—' : `${s.sensitive_acceptance_rate ?? 0}%`}
</td>
</tr>
)
})}
{(!summary || summary.length === 0) && (
<tr><td colSpan={8} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
<tr><td colSpan={11} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
)}
</tbody>
</table>
@@ -134,6 +147,7 @@ export default function MitraActivityPage() {
<tr style={{ borderBottom: '2px solid #ddd', textAlign: 'left' }}>
<th style={{ padding: 8 }}>Mitra</th>
<th style={{ padding: 8 }}>Session</th>
<th style={{ padding: 8 }}>Topik</th>
<th style={{ padding: 8 }}>Response</th>
<th style={{ padding: 8 }}>Response Time (s)</th>
<th style={{ padding: 8 }}>Active Sessions</th>
@@ -146,6 +160,13 @@ export default function MitraActivityPage() {
<tr key={item.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{item.mitra_display_name}</td>
<td style={{ padding: 8, fontSize: 11, fontFamily: 'monospace' }}>{item.session_id?.substring(0, 8)}...</td>
<td style={{ padding: 8 }}>
{item.topic_sensitivity === 'sensitive' ? (
<span style={{ background: '#F7B500', color: '#3D2A00', padding: '2px 6px', borderRadius: 999, fontSize: 11, fontWeight: 600 }}>Sensitif</span>
) : (
<span style={{ color: '#666', fontSize: 11 }}>Umum</span>
)}
</td>
<td style={{ padding: 8 }}>
<span style={{ color: responseColor(item.response), fontWeight: 'bold' }}>
{item.response || '-'}
@@ -158,7 +179,7 @@ export default function MitraActivityPage() {
</tr>
))}
{(!logData?.items || logData.items.length === 0) && (
<tr><td colSpan={7} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
<tr><td colSpan={8} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
)}
</tbody>
</table>

View File

@@ -1,10 +1,11 @@
import { useState } from 'react'
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
const fetchSessions = async ({ status, page }) => {
const fetchSessions = async ({ status, topic_sensitivity, page }) => {
const params = new URLSearchParams()
if (status) params.set('status', status)
if (topic_sensitivity && topic_sensitivity !== 'all') params.set('topic_sensitivity', topic_sensitivity)
params.set('page', page)
params.set('limit', '20')
const res = await apiClient.get(`/internal/sessions?${params}`)
@@ -21,6 +22,11 @@ const rerouteSession = async ({ sessionId, new_mitra_id }) => {
return res.data.data
}
const fetchSessionDetail = async (sessionId) => {
const res = await apiClient.get(`/internal/sessions/${sessionId}`)
return res.data.data
}
const STATUS_OPTIONS = [
{ value: '', label: 'Semua' },
{ value: 'active', label: 'Aktif' },
@@ -31,15 +37,44 @@ const STATUS_OPTIONS = [
{ value: 'expired', label: 'Kedaluwarsa' },
]
const TOPIC_OPTIONS = [
{ value: 'all', label: 'Semua' },
{ value: 'regular', label: 'Umum' },
{ value: 'sensitive', label: 'Sensitif' },
]
export default function SessionsPage() {
const queryClient = useQueryClient()
const [statusFilter, setStatusFilter] = useState('')
const [topicFilter, setTopicFilter] = useState('all')
const [page, setPage] = useState(1)
const [rerouteTarget, setRerouteTarget] = useState({})
const [expandedId, setExpandedId] = useState(null)
const [detail, setDetail] = useState(null)
const [detailLoading, setDetailLoading] = useState(false)
const toggleExpand = async (sessionId) => {
if (expandedId === sessionId) {
setExpandedId(null)
setDetail(null)
return
}
setExpandedId(sessionId)
setDetail(null)
setDetailLoading(true)
try {
const d = await fetchSessionDetail(sessionId)
setDetail(d)
} catch (_) {
setDetail({ error: true })
} finally {
setDetailLoading(false)
}
}
const { data, isLoading } = useQuery({
queryKey: ['sessions', statusFilter, page],
queryFn: () => fetchSessions({ status: statusFilter, page }),
queryKey: ['sessions', statusFilter, topicFilter, page],
queryFn: () => fetchSessions({ status: statusFilter, topic_sensitivity: topicFilter, page }),
refetchInterval: 10000,
})
@@ -62,11 +97,19 @@ export default function SessionsPage() {
<div>
<h1>Sesi</h1>
<div style={{ marginBottom: 16, display: 'flex', gap: 8, alignItems: 'center' }}>
<label>Filter: </label>
<select value={statusFilter} onChange={e => { setStatusFilter(e.target.value); setPage(1) }}>
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label>Status: </label>
<select value={statusFilter} onChange={e => { setStatusFilter(e.target.value); setPage(1) }}>
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label>Topik: </label>
<select value={topicFilter} onChange={e => { setTopicFilter(e.target.value); setPage(1) }}>
{TOPIC_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
@@ -75,44 +118,87 @@ export default function SessionsPage() {
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Topik</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Waktu</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr>
</thead>
<tbody>
{data?.items?.map((session) => (
<tr key={session.id}>
<React.Fragment key={session.id}>
<tr>
<td style={{ padding: 8 }}>{session.customer_display_name}</td>
<td style={{ padding: 8 }}>{session.mitra_display_name ?? '-'}</td>
<td style={{ padding: 8 }}>{session.status}</td>
<td style={{ padding: 8 }}>{new Date(session.created_at).toLocaleString('id-ID')}</td>
<td style={{ padding: 8 }}>
{['active', 'pending_payment'].includes(session.status) && (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<select
value={rerouteTarget[session.id] ?? ''}
onChange={e => setRerouteTarget(t => ({ ...t, [session.id]: e.target.value }))}
style={{ fontSize: 12 }}
>
<option value="">Reroute ke...</option>
{(onlineMitras ?? [])
.filter(m => m.id !== session.mitra_id)
.map(m => <option key={m.id} value={m.id}>{m.display_name}</option>)}
</select>
<button
disabled={!rerouteTarget[session.id] || rerouteMutation.isPending}
onClick={() => rerouteMutation.mutate({
sessionId: session.id,
new_mitra_id: rerouteTarget[session.id],
})}
style={{ fontSize: 12 }}
>
Reroute
</button>
</div>
{session.topic_sensitivity === 'sensitive' ? (
<span style={{ background: '#F7B500', color: '#3D2A00', padding: '2px 8px', borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
Sensitif
</span>
) : (
<span style={{ color: '#666', fontSize: 12 }}>Umum</span>
)}
</td>
<td style={{ padding: 8 }}>{new Date(session.created_at).toLocaleString('id-ID')}</td>
<td style={{ padding: 8 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => toggleExpand(session.id)} style={{ fontSize: 12 }}>
{expandedId === session.id ? 'Tutup' : 'Detail'}
</button>
{['active', 'pending_payment'].includes(session.status) && (
<>
<select
value={rerouteTarget[session.id] ?? ''}
onChange={e => setRerouteTarget(t => ({ ...t, [session.id]: e.target.value }))}
style={{ fontSize: 12 }}
>
<option value="">Reroute ke...</option>
{(onlineMitras ?? [])
.filter(m => m.id !== session.mitra_id)
.map(m => <option key={m.id} value={m.id}>{m.display_name}</option>)}
</select>
<button
disabled={!rerouteTarget[session.id] || rerouteMutation.isPending}
onClick={() => rerouteMutation.mutate({
sessionId: session.id,
new_mitra_id: rerouteTarget[session.id],
})}
style={{ fontSize: 12 }}
>
Reroute
</button>
</>
)}
</div>
</td>
</tr>
{expandedId === session.id && (
<tr>
<td colSpan={6} style={{ padding: 16, background: '#fafafa' }}>
{detailLoading && <div>Memuat detail</div>}
{detail?.error && <div style={{ color: 'red' }}>Gagal memuat detail sesi.</div>}
{detail && !detail.error && (
<div>
<h3 style={{ margin: '0 0 8px' }}>Riwayat Topik Sensitif</h3>
{(!detail.sensitivity_log || detail.sensitivity_log.length === 0) ? (
<p style={{ color: '#666', fontSize: 13 }}>Belum ada perubahan topik oleh Mitra.</p>
) : (
<ul style={{ margin: 0, paddingLeft: 20 }}>
{detail.sensitivity_log.map(log => (
<li key={log.id} style={{ fontSize: 13, marginBottom: 4 }}>
<strong>{log.changed_by_mitra_name ?? 'Mitra'}</strong>{' '}
mengubah dari <code>{log.from_value}</code> menjadi <code>{log.to_value}</code>
{' '}pada {new Date(log.created_at).toLocaleString('id-ID')}
</li>
))}
</ul>
)}
</div>
)}
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>

View File

@@ -63,6 +63,17 @@ const updateEarlyEndConfig = async (data) => {
return res.data.data
}
// Phase 3.3: Topic Sensitivity
const fetchSensitivityConfig = async () => {
const res = await apiClient.get('/internal/config/sensitivity')
return res.data.data
}
const updateSensitivityConfig = async (data) => {
const res = await apiClient.patch('/internal/config/sensitivity', data)
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
@@ -122,7 +133,17 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-mitra-ping'] }),
})
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading) return <div>Loading...</div>
// Phase 3.3: Topic Sensitivity
const { data: senData, isLoading: senLoading } = useQuery({
queryKey: ['config-sensitivity'],
queryFn: fetchSensitivityConfig,
})
const senMutation = useMutation({
mutationFn: updateSensitivityConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-sensitivity'] }),
})
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading) return <div>Loading...</div>
return (
<div>
@@ -269,6 +290,36 @@ export default function SettingsPage() {
</div>
{mpMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Sensitivitas Topik</h2>
<p>Konfigurasi untuk fitur penandaan sesi sebagai topik sensitif oleh Mitra.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={senData?.flip_confirmation_enabled ?? true}
onChange={e => senMutation.mutate({ flip_confirmation_enabled: e.target.checked })}
disabled={senMutation.isPending}
/>
Aktifkan dialog konfirmasi saat Mitra menandai topik sensitif
</label>
<p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
Jika dinonaktifkan, Mitra langsung menandai tanpa dialog konfirmasi.
</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={senData?.one_way_latch ?? false}
onChange={e => senMutation.mutate({ one_way_latch: e.target.checked })}
disabled={senMutation.isPending}
/>
Kunci searah Mitra tidak bisa membatalkan tanda topik sensitif
</label>
<p style={{ fontSize: 12, color: '#666' }}>
Jika diaktifkan, setelah sesi ditandai sensitif Mitra tidak dapat mengembalikannya ke topik umum.
</p>
{senMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)
}