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

@@ -59,6 +59,27 @@ export const EndedBy = Object.freeze({
MITRA: 'mitra',
})
// Session topic sensitivity
export const TopicSensitivity = Object.freeze({
REGULAR: 'regular',
SENSITIVE: 'sensitive',
})
// Auth provider used to establish a session
export const AuthProvider = Object.freeze({
ANONYMOUS: 'anonymous',
PHONE: 'phone',
GOOGLE: 'google',
APPLE: 'apple',
PASSWORD: 'password',
})
// OTP delivery channel
export const OtpChannel = Object.freeze({
WHATSAPP: 'whatsapp',
SMS: 'sms',
})
// WebSocket message types
export const WsMessage = Object.freeze({
// Auth
@@ -93,6 +114,9 @@ export const WsMessage = Object.freeze({
EXTENSION_REQUEST: 'extension_request',
EXTENSION_RESPONSE: 'extension_response',
// Topic sensitivity
SESSION_TOPIC_UPDATED: 'session_topic_updated',
// Early end
EARLY_END: 'early_end',

View File

@@ -300,6 +300,115 @@ const migrate = async () => {
ON chat_request_notifications (mitra_id, notified_at)
`
// --- Phase 3.3: Session Topic Sensitivity ---
await sql`
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS topic_sensitivity VARCHAR(16) NOT NULL DEFAULT 'regular'
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_topic_sensitivity
ON chat_sessions (topic_sensitivity)
`
await sql`
CREATE TABLE IF NOT EXISTS session_sensitivity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
changed_by_mitra_id UUID NOT NULL REFERENCES mitras(id),
from_value VARCHAR(16) NOT NULL,
to_value VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_session_sensitivity_log_session
ON session_sensitivity_log (session_id)
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('sensitive_flip_confirmation_enabled', '{"value": true}')
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('sensitive_flag_one_way_latch', '{"value": false}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 3.4: Self-Managed Auth ---
// Customers: add social identity columns
await sql`
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS email VARCHAR(255),
ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255),
ADD COLUMN IF NOT EXISTS apple_sub VARCHAR(255)
`
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_google_sub ON customers (google_sub) WHERE google_sub IS NOT NULL`
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_apple_sub ON customers (apple_sub) WHERE apple_sub IS NOT NULL`
// Control center users: password-based auth columns
// firebase_uid stays for backward compat during migration; will be dropped in a later cleanup migration
await sql`ALTER TABLE control_center_users ALTER COLUMN firebase_uid DROP NOT NULL`
await sql`
ALTER TABLE control_center_users
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(60) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS failed_login_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS lockout_until TIMESTAMPTZ
`
// Auth sessions (refresh tokens + multi-device)
await sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_type VARCHAR(16) NOT NULL,
user_id UUID NOT NULL,
refresh_token_hash VARCHAR(60) NOT NULL,
device_info JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
)
`
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions (user_type, user_id)`
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL`
// OTP requests (Fazpass reference + rate-limit tracking)
await sql`
CREATE TABLE IF NOT EXISTS otp_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) NOT NULL,
ip_address VARCHAR(45),
user_type VARCHAR(16) NOT NULL,
fazpass_reference VARCHAR(255),
channel VARCHAR(16),
attempts INT NOT NULL DEFAULT 0,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
)
`
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at)`
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_ip_created ON otp_requests (ip_address, created_at)`
// Auth-related app_config defaults
await sql`
INSERT INTO app_config (key, value) VALUES
('otp_max_per_phone_per_hour', '{"value": 3}'),
('otp_max_per_ip_per_hour', '{"value": 10}'),
('otp_resend_cooldown_seconds', '{"value": 60}'),
('otp_verify_max_attempts', '{"value": 5}'),
('cc_login_max_attempts', '{"value": 5}'),
('cc_login_lockout_minutes', '{"value": 15}')
ON CONFLICT (key) DO NOTHING
`
console.log('Migration complete.')
await sql.end()
}

View File

@@ -7,6 +7,7 @@ import {
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
getEarlyEndConfig, setEarlyEndConfig,
getMitraPingConfig, setMitraPingConfig,
getSensitivityConfig, setSensitivityConfig,
} from '../../services/config.service.js'
const attachCcUser = async (request, reply) => {
@@ -125,6 +126,28 @@ export const internalConfigRoutes = async (app) => {
return reply.send({ success: true, data: config })
})
// --- Phase 3.3: Topic Sensitivity ---
app.get('/sensitivity', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const config = await getSensitivityConfig()
return reply.send({ success: true, data: config })
})
app.patch('/sensitivity', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { flip_confirmation_enabled, one_way_latch } = request.body ?? {}
if (flip_confirmation_enabled !== undefined && typeof flip_confirmation_enabled !== 'boolean') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'flip_confirmation_enabled must be a boolean' } })
}
if (one_way_latch !== undefined && typeof one_way_latch !== 'boolean') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'one_way_latch must be a boolean' } })
}
const config = await setSensitivityConfig({ flip_confirmation_enabled, one_way_latch })
return reply.send({ success: true, data: config })
})
// --- Price Tiers ---
app.get('/price-tiers', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],

View File

@@ -1,6 +1,7 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
import { listSessions, getSessionById, rerouteSession } from '../../services/session.service.js'
import { getSessionSensitivityLog } from '../../services/sensitivity.service.js'
import { getDashboardStats } from '../../services/dashboard.service.js'
const attachCcUser = async (request, reply) => {
@@ -20,8 +21,13 @@ export const sessionManagementRoutes = async (app) => {
app.get('/', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const { page = 1, limit = 20, status } = request.query
const result = await listSessions({ page: Number(page), limit: Number(limit), status })
const { page = 1, limit = 20, status, topic_sensitivity } = request.query
const result = await listSessions({
page: Number(page),
limit: Number(limit),
status,
topic_sensitivity: topic_sensitivity && topic_sensitivity !== 'all' ? topic_sensitivity : undefined,
})
return reply.send({ success: true, data: result })
})
@@ -32,7 +38,8 @@ export const sessionManagementRoutes = async (app) => {
if (!session) {
return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } })
}
return reply.send({ success: true, data: session })
const sensitivity_log = await getSessionSensitivityLog(request.params.sessionId)
return reply.send({ success: true, data: { ...session, sensitivity_log } })
})
app.post('/:sessionId/reroute', {

View File

@@ -4,7 +4,7 @@ import { createPairingRequest, cancelPairingRequest } from '../../services/pairi
import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory } from '../../services/session.service.js'
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
import { requestExtension } from '../../services/extension.service.js'
import { EndedBy } from '../../constants.js'
import { EndedBy, TopicSensitivity } from '../../constants.js'
const resolveCustomer = async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
@@ -25,7 +25,14 @@ export const clientChatRoutes = async (app) => {
})
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { duration_minutes, price, is_free_trial } = request.body || {}
const { duration_minutes, price, is_free_trial, topic_sensitivity } = request.body || {}
if (topic_sensitivity !== TopicSensitivity.REGULAR && topic_sensitivity !== TopicSensitivity.SENSITIVE) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'topic_sensitivity must be regular or sensitive' },
})
}
// Validate selection
if (is_free_trial) {
@@ -41,6 +48,7 @@ export const clientChatRoutes = async (app) => {
duration_minutes: freeTrial.duration_minutes,
price: 0,
is_free_trial: true,
topic_sensitivity,
})
return reply.code(201).send({ success: true, data: session })
}
@@ -59,7 +67,7 @@ export const clientChatRoutes = async (app) => {
})
}
const session = await createPairingRequest(request.customer.id, { duration_minutes, price, is_free_trial: false })
const session = await createPairingRequest(request.customer.id, { duration_minutes, price, is_free_trial: false, topic_sensitivity })
return reply.code(201).send({ success: true, data: session })
})

View File

@@ -4,8 +4,9 @@ import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
import { getMessages } from '../../services/chat.service.js'
import { getSessionClosures } from '../../services/closure.service.js'
import { registerDeviceToken } from '../../services/notification.service.js'
import { flipSessionSensitivity } from '../../services/sensitivity.service.js'
import { getDb } from '../../db/client.js'
import { UserType } from '../../constants.js'
import { TopicSensitivity, UserType } from '../../constants.js'
const sql = getDb()
@@ -96,4 +97,35 @@ export const sharedChatRoutes = async (app) => {
const closure = await submitClosureMessage(sessionId, request.userType, request.userId, message)
return reply.send({ success: true, data: closure })
})
// Mitra flips session topic sensitivity (regular <-> sensitive)
app.patch('/chat/sessions/:sessionId/topic', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
if (request.userType !== UserType.MITRA) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Only mitra can change topic sensitivity' },
})
}
const { sessionId } = request.params
const { topic_sensitivity } = request.body || {}
if (topic_sensitivity !== TopicSensitivity.REGULAR && topic_sensitivity !== TopicSensitivity.SENSITIVE) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'topic_sensitivity must be regular or sensitive' },
})
}
try {
const result = await flipSessionSensitivity({
sessionId,
mitraId: request.userId,
toValue: topic_sensitivity,
})
return reply.send({ success: true, data: result })
} catch (err) {
return reply.code(err.statusCode || 500).send({
success: false,
error: { code: err.code || 'INTERNAL', message: err.message },
})
}
})
}

View File

@@ -1,9 +1,14 @@
import { authenticate } from '../../plugins/auth.js'
import { getAnonymityConfig } from '../../services/config.service.js'
import { getAnonymityConfig, getSensitivityConfig } from '../../services/config.service.js'
export const sharedConfigRoutes = async (app) => {
app.get('/anonymity', async (request, reply) => {
const config = await getAnonymityConfig()
return reply.send({ success: true, data: config })
})
app.get('/sensitivity', { preHandler: [authenticate] }, async (request, reply) => {
const config = await getSensitivityConfig()
return reply.send({ success: true, data: config })
})
}

View File

@@ -128,3 +128,97 @@ export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) =>
}
return getEarlyEndConfig()
}
// --- Phase 3.3: Session Topic Sensitivity ---
export const getSensitivityConfig = async () => {
const [confirmRow] = await sql`SELECT value FROM app_config WHERE key = 'sensitive_flip_confirmation_enabled'`
const [latchRow] = await sql`SELECT value FROM app_config WHERE key = 'sensitive_flag_one_way_latch'`
return {
flip_confirmation_enabled: confirmRow?.value?.value ?? true,
one_way_latch: latchRow?.value?.value ?? false,
}
}
export const setSensitivityConfig = async ({ flip_confirmation_enabled, one_way_latch }) => {
if (flip_confirmation_enabled !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('sensitive_flip_confirmation_enabled', ${sql.json({ value: flip_confirmation_enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (one_way_latch !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('sensitive_flag_one_way_latch', ${sql.json({ value: one_way_latch })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getSensitivityConfig()
}
// --- Phase 3.4: Self-Managed Auth ---
export const getOtpRateLimits = async () => {
const [phoneRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_max_per_phone_per_hour'`
const [ipRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_max_per_ip_per_hour'`
const [resendRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_resend_cooldown_seconds'`
const [attemptsRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_verify_max_attempts'`
return {
max_per_phone_per_hour: phoneRow?.value?.value ?? 3,
max_per_ip_per_hour: ipRow?.value?.value ?? 10,
resend_cooldown_seconds: resendRow?.value?.value ?? 60,
verify_max_attempts: attemptsRow?.value?.value ?? 5,
}
}
export const setOtpRateLimits = async ({
max_per_phone_per_hour,
max_per_ip_per_hour,
resend_cooldown_seconds,
verify_max_attempts,
}) => {
const pairs = [
['otp_max_per_phone_per_hour', max_per_phone_per_hour],
['otp_max_per_ip_per_hour', max_per_ip_per_hour],
['otp_resend_cooldown_seconds', resend_cooldown_seconds],
['otp_verify_max_attempts', verify_max_attempts],
]
for (const [key, value] of pairs) {
if (value === undefined) continue
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES (${key}, ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getOtpRateLimits()
}
export const getCcLoginLockoutConfig = async () => {
const [attemptsRow] = await sql`SELECT value FROM app_config WHERE key = 'cc_login_max_attempts'`
const [minutesRow] = await sql`SELECT value FROM app_config WHERE key = 'cc_login_lockout_minutes'`
return {
max_attempts: attemptsRow?.value?.value ?? 5,
lockout_minutes: minutesRow?.value?.value ?? 15,
}
}
export const setCcLoginLockoutConfig = async ({ max_attempts, lockout_minutes }) => {
if (max_attempts !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('cc_login_max_attempts', ${sql.json({ value: max_attempts })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (lockout_minutes !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('cc_login_lockout_minutes', ${sql.json({ value: lockout_minutes })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getCcLoginLockoutConfig()
}

View File

@@ -1,13 +1,23 @@
import { getDb } from '../db/client.js'
import { SessionStatus } from '../constants.js'
import { SessionStatus, TopicSensitivity } from '../constants.js'
const sql = getDb()
export const getDashboardStats = async () => {
const [[{ active_chats }], [{ online_mitras }], [{ pending_requests }]] = await Promise.all([
const [
[{ active_chats }],
[{ online_mitras }],
[{ pending_requests }],
[{ sensitive_total }],
[{ sensitive_last_30d_total }],
[{ sensitive_last_30d_sensitive }],
] = await Promise.all([
sql`SELECT COUNT(*) AS active_chats FROM chat_sessions WHERE status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})`,
sql`SELECT COUNT(*) AS online_mitras FROM mitra_online_status WHERE is_online = true`,
sql`SELECT COUNT(*) AS pending_requests FROM chat_sessions WHERE status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})`,
sql`SELECT COUNT(*) AS sensitive_total FROM chat_sessions WHERE topic_sensitivity = ${TopicSensitivity.SENSITIVE}`,
sql`SELECT COUNT(*) AS sensitive_last_30d_total FROM chat_sessions WHERE created_at >= NOW() - INTERVAL '30 days'`,
sql`SELECT COUNT(*) AS sensitive_last_30d_sensitive FROM chat_sessions WHERE created_at >= NOW() - INTERVAL '30 days' AND topic_sensitivity = ${TopicSensitivity.SENSITIVE}`,
])
const customersPerMitra = await sql`
@@ -20,10 +30,19 @@ export const getDashboardStats = async () => {
ORDER BY active_session_count DESC
`
const last30dTotal = Number(sensitive_last_30d_total)
const last30dSensitive = Number(sensitive_last_30d_sensitive)
return {
active_chats: Number(active_chats),
online_mitras: Number(online_mitras),
pending_requests: Number(pending_requests),
customers_per_mitra: customersPerMitra,
sensitive: {
total: Number(sensitive_total),
last_30d_total: last30dTotal,
last_30d_sensitive: last30dSensitive,
last_30d_percent: last30dTotal > 0 ? Math.round((last30dSensitive / last30dTotal) * 1000) / 10 : 0,
},
}
}

View File

@@ -17,7 +17,7 @@ const getExtensionTimeout = async () => {
export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => {
// Verify session belongs to customer and is in an extendable state
const [session] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
SELECT id, customer_id, mitra_id, status, topic_sensitivity FROM chat_sessions
WHERE id = ${sessionId} AND customer_id = ${customerId}
AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.CLOSING})
`
@@ -35,13 +35,14 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
// Pause the session
await sql`UPDATE chat_sessions SET status = ${SessionStatus.EXTENDING} WHERE id = ${sessionId}`
// Notify mitra
// Notify mitra — include current topic sensitivity so UI can highlight
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.EXTENSION_REQUEST,
extension_id: extension.id,
session_id: sessionId,
duration_minutes,
price,
topic_sensitivity: session.topic_sensitivity,
})
// Notify customer that chat is paused

View File

@@ -1,4 +1,5 @@
import { getDb } from '../db/client.js'
import { TopicSensitivity } from '../constants.js'
const sql = getDb()
@@ -18,12 +19,14 @@ export const getMitraActivityLog = async ({ mitra_id, date_from, date_to, page =
SELECT crn.id, crn.session_id, crn.mitra_id, crn.response,
crn.notified_at, crn.responded_at, crn.active_session_count,
m.display_name AS mitra_display_name,
cs.topic_sensitivity,
CASE WHEN crn.responded_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (crn.responded_at - crn.notified_at))::int
ELSE NULL
END AS response_time_seconds
FROM chat_request_notifications crn
INNER JOIN mitras m ON m.id = crn.mitra_id
LEFT JOIN chat_sessions cs ON cs.id = crn.session_id
${where}
ORDER BY crn.notified_at DESC
LIMIT ${limit} OFFSET ${offset}
@@ -63,9 +66,17 @@ export const getMitraActivitySummary = async ({ mitra_id, date_from, date_to } =
THEN EXTRACT(EPOCH FROM (crn.responded_at - crn.notified_at))
ELSE NULL
END
)::numeric(10,1) AS avg_response_time_seconds
)::numeric(10,1) AS avg_response_time_seconds,
COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE})::int AS sensitive_total,
COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE} AND crn.response = 'accepted')::int AS sensitive_accepted,
COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE} AND crn.response = 'declined')::int AS sensitive_rejected,
ROUND(
100.0 * COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE} AND crn.response = 'accepted')
/ NULLIF(COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE}), 0), 1
) AS sensitive_acceptance_rate
FROM chat_request_notifications crn
INNER JOIN mitras m ON m.id = crn.mitra_id
LEFT JOIN chat_sessions cs ON cs.id = crn.session_id
${where}
GROUP BY crn.mitra_id, m.display_name
ORDER BY acceptance_rate DESC NULLS LAST

View File

@@ -0,0 +1,170 @@
import crypto from 'node:crypto'
import { getDb } from '../db/client.js'
import { getOtpRateLimits } from './config.service.js'
import { OtpChannel, UserType } from '../constants.js'
const sql = getDb()
const OTP_TTL_MINUTES = 5
// -------------------------------------------------------------------
// ⚠ Fazpass integration — STUB until real API docs are obtained.
//
// In production, Fazpass is the source of truth for the OTP code.
// We will only ever handle a reference ID (string) returned by Fazpass,
// never the raw code. For now, we generate a 6-digit code locally and
// store its bcrypt hash in the metadata field of otp_requests via
// fazpass_reference (reused as "<reference>:<hash>") so the stub can
// round-trip without schema changes.
//
// When real docs arrive, replace fazpassSendStub + fazpassVerifyStub
// with real HTTP calls and drop the local code generation.
// -------------------------------------------------------------------
const generate6DigitCode = () => {
// Avoid Math.random for OTP generation — use crypto.randomInt
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
}
const fazpassSendStub = async ({ phone, channel }) => {
const reference = `stub_${crypto.randomUUID()}`
const code = generate6DigitCode()
// Log the code so developers can read it during dev testing.
// eslint-disable-next-line no-console
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
return { reference, channel_used: channel, code } // `code` only present in stub
}
const fazpassVerifyStub = async ({ reference, code, expectedCode }) => {
return { valid: code === expectedCode }
}
// -------------------------------------------------------------------
export class OtpError extends Error {
constructor(message, code, statusCode) {
super(message)
this.code = code
this.statusCode = statusCode
}
}
const checkRateLimits = async ({ phone, ipAddress, limits }) => {
// Resend cooldown
const [lastRow] = await sql`
SELECT created_at FROM otp_requests
WHERE phone = ${phone}
ORDER BY created_at DESC LIMIT 1
`
if (lastRow) {
const elapsed = (Date.now() - new Date(lastRow.created_at).getTime()) / 1000
if (elapsed < limits.resend_cooldown_seconds) {
throw new OtpError(
`Please wait ${Math.ceil(limits.resend_cooldown_seconds - elapsed)}s before requesting another OTP`,
'OTP_COOLDOWN', 429,
)
}
}
// Per-phone hourly limit
const [{ phone_count }] = await sql`
SELECT COUNT(*)::int AS phone_count FROM otp_requests
WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour'
`
if (phone_count >= limits.max_per_phone_per_hour) {
throw new OtpError('Too many OTP requests for this number', 'OTP_RATE_LIMIT_PHONE', 429)
}
// Per-IP hourly limit (only if ip provided)
if (ipAddress) {
const [{ ip_count }] = await sql`
SELECT COUNT(*)::int AS ip_count FROM otp_requests
WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour'
`
if (ip_count >= limits.max_per_ip_per_hour) {
throw new OtpError('Too many OTP requests from this network', 'OTP_RATE_LIMIT_IP', 429)
}
}
}
/**
* Start an OTP flow. Returns { otp_request_id, channel_used, expires_at }.
* Does NOT return the code to the caller.
*/
export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP }) => {
if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) {
throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422)
}
if (userType !== UserType.CUSTOMER && userType !== UserType.MITRA) {
throw new OtpError('Invalid user type', 'USER_TYPE_INVALID', 400)
}
const limits = await getOtpRateLimits()
await checkRateLimits({ phone, ipAddress, limits })
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
// Store the reference. In stub mode, we also store the expected code appended
// after a colon so the verify stub can compare. Real Fazpass flow will NOT store
// the code; Fazpass itself holds it. This line is the main place to change
// when switching to real Fazpass.
const storedReference = code ? `${reference}:${code}` : reference
const [row] = await sql`
INSERT INTO otp_requests (phone, ip_address, user_type, fazpass_reference, channel, expires_at)
VALUES (
${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval
)
RETURNING id, expires_at
`
return {
otp_request_id: row.id,
channel_used,
expires_at: row.expires_at,
}
}
/**
* Verify an OTP code. Returns { phone, user_type } on success.
* Throws OtpError on failure.
*/
export const verifyOtp = async ({ otpRequestId, code }) => {
if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) {
throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
}
const limits = await getOtpRateLimits()
const [row] = await sql`
SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at
FROM otp_requests
WHERE id = ${otpRequestId}
`
if (!row) {
throw new OtpError('OTP request not found', 'OTP_NOT_FOUND', 404)
}
if (row.used_at) {
throw new OtpError('OTP already used', 'OTP_USED', 409)
}
if (new Date(row.expires_at) <= new Date()) {
throw new OtpError('OTP expired', 'OTP_EXPIRED', 410)
}
if (row.attempts >= limits.verify_max_attempts) {
throw new OtpError('Too many verification attempts; request a new OTP', 'OTP_ATTEMPTS_EXCEEDED', 429)
}
await sql`UPDATE otp_requests SET attempts = attempts + 1 WHERE id = ${otpRequestId}`
// Stub: fazpass_reference is stored as "<ref>:<code>"
const [reference, expectedCode] = (row.fazpass_reference || '').split(':')
const { valid } = await fazpassVerifyStub({ reference, code, expectedCode })
if (!valid) {
throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401)
}
await sql`UPDATE otp_requests SET used_at = NOW() WHERE id = ${otpRequestId}`
return { phone: row.phone, user_type: row.user_type }
}

View File

@@ -4,7 +4,7 @@ import { sendToUser } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js'
import { startSessionTimer } from './session-timer.service.js'
import { startSessionListener } from './chat-handler.service.js'
import { UserType, SessionStatus, NotificationResponse, TransactionType, WsMessage } from '../constants.js'
import { UserType, SessionStatus, NotificationResponse, TransactionType, WsMessage, TopicSensitivity } from '../constants.js'
const sql = getDb()
@@ -63,7 +63,7 @@ export const findAvailableMitras = async () => {
return mitras
}
export const createPairingRequest = async (customerId, { duration_minutes, price, is_free_trial } = {}) => {
export const createPairingRequest = async (customerId, { duration_minutes, price, is_free_trial, topic_sensitivity } = {}) => {
// Check for existing active session or request
const [existing] = await sql`
SELECT id, status FROM chat_sessions
@@ -83,11 +83,15 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
})
}
// Create session with duration/price
const resolvedTopic = topic_sensitivity === TopicSensitivity.SENSITIVE
? TopicSensitivity.SENSITIVE
: TopicSensitivity.REGULAR
// Create session with duration/price/topic
const [session] = await sql`
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial)
VALUES (${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false})
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, created_at
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity)
VALUES (${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false}, ${resolvedTopic})
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, created_at
`
// Create notifications for all available mitras
@@ -108,6 +112,7 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
created_at: session.created_at,
duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial,
topic_sensitivity: session.topic_sensitivity,
})
}
@@ -309,7 +314,7 @@ export const expirePairingRequest = async (sessionId) => {
export const getPendingRequestsForMitra = async (mitraId) => {
const rows = await sql`
SELECT cs.id AS session_id, cs.duration_minutes, cs.is_free_trial, cs.created_at
SELECT cs.id AS session_id, cs.duration_minutes, cs.is_free_trial, cs.topic_sensitivity, cs.created_at
FROM chat_request_notifications crn
JOIN chat_sessions cs ON cs.id = crn.session_id
WHERE crn.mitra_id = ${mitraId}
@@ -322,7 +327,8 @@ export const getPendingRequestsForMitra = async (mitraId) => {
export const getSessionStatus = async (sessionId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
m.display_name AS mitra_display_name
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id

View File

@@ -0,0 +1,36 @@
import bcrypt from 'bcrypt'
const BCRYPT_COST = 12
// Password complexity: min 8 chars, >=1 digit, >=1 upper, >=1 lower
export const validatePasswordComplexity = (plain) => {
if (typeof plain !== 'string' || plain.length < 8) {
throw Object.assign(new Error('Password must be at least 8 characters'), {
code: 'PASSWORD_TOO_SHORT', statusCode: 422,
})
}
if (!/[0-9]/.test(plain)) {
throw Object.assign(new Error('Password must contain at least one digit'), {
code: 'PASSWORD_MISSING_DIGIT', statusCode: 422,
})
}
if (!/[A-Z]/.test(plain)) {
throw Object.assign(new Error('Password must contain at least one uppercase letter'), {
code: 'PASSWORD_MISSING_UPPERCASE', statusCode: 422,
})
}
if (!/[a-z]/.test(plain)) {
throw Object.assign(new Error('Password must contain at least one lowercase letter'), {
code: 'PASSWORD_MISSING_LOWERCASE', statusCode: 422,
})
}
}
export const hashPassword = async (plain) => {
return bcrypt.hash(plain, BCRYPT_COST)
}
export const verifyPassword = async (plain, hash) => {
if (!hash) return false
return bcrypt.compare(plain, hash)
}

View File

@@ -0,0 +1,90 @@
import { getDb } from '../db/client.js'
import { sendToSessionParticipant } from '../plugins/websocket.js'
import { getSensitivityConfig } from './config.service.js'
import { SessionStatus, TopicSensitivity, UserType, WsMessage } from '../constants.js'
const sql = getDb()
const FLIPPABLE_STATUSES = [SessionStatus.ACTIVE, SessionStatus.EXTENDING]
export const flipSessionSensitivity = async ({ sessionId, mitraId, toValue }) => {
if (toValue !== TopicSensitivity.REGULAR && toValue !== TopicSensitivity.SENSITIVE) {
throw Object.assign(new Error('Invalid topic_sensitivity value'), {
code: 'INVALID_TOPIC', statusCode: 400,
})
}
const [session] = await sql`
SELECT id, mitra_id, status, topic_sensitivity
FROM chat_sessions
WHERE id = ${sessionId}
`
if (!session) {
throw Object.assign(new Error('Session not found'), {
code: 'SESSION_NOT_FOUND', statusCode: 404,
})
}
if (session.mitra_id !== mitraId) {
throw Object.assign(new Error('Not allowed'), {
code: 'FORBIDDEN', statusCode: 403,
})
}
if (!FLIPPABLE_STATUSES.includes(session.status)) {
throw Object.assign(new Error('Session is not in a flippable state'), {
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
})
}
const fromValue = session.topic_sensitivity
if (fromValue === toValue) {
return { session_id: sessionId, topic_sensitivity: fromValue, changed: false }
}
const { one_way_latch } = await getSensitivityConfig()
if (one_way_latch && fromValue === TopicSensitivity.SENSITIVE && toValue === TopicSensitivity.REGULAR) {
throw Object.assign(new Error('Sensitive flag is locked and cannot be reverted'), {
code: 'SENSITIVITY_LATCHED', statusCode: 409,
})
}
const [log] = await sql.begin(async (tx) => {
await tx`
UPDATE chat_sessions
SET topic_sensitivity = ${toValue}
WHERE id = ${sessionId}
`
return tx`
INSERT INTO session_sensitivity_log (session_id, changed_by_mitra_id, from_value, to_value)
VALUES (${sessionId}, ${mitraId}, ${fromValue}, ${toValue})
RETURNING id, created_at
`
})
const payload = {
type: WsMessage.SESSION_TOPIC_UPDATED,
session_id: sessionId,
topic_sensitivity: toValue,
changed_at: log.created_at,
}
sendToSessionParticipant(sessionId, UserType.CUSTOMER, payload)
sendToSessionParticipant(sessionId, UserType.MITRA, payload)
return {
session_id: sessionId,
topic_sensitivity: toValue,
changed: true,
changed_at: log.created_at,
}
}
export const getSessionSensitivityLog = async (sessionId) => {
const rows = await sql`
SELECT l.id, l.session_id, l.changed_by_mitra_id, m.display_name AS changed_by_mitra_name,
l.from_value, l.to_value, l.created_at
FROM session_sensitivity_log l
LEFT JOIN mitras m ON m.id = l.changed_by_mitra_id
WHERE l.session_id = ${sessionId}
ORDER BY l.created_at ASC
`
return rows
}

View File

@@ -6,7 +6,7 @@ const sql = getDb()
export const getActiveSessionByCustomer = async (customerId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
m.display_name AS mitra_display_name
FROM chat_sessions cs
@@ -20,7 +20,7 @@ export const getActiveSessionByCustomer = async (customerId) => {
export const getActiveSessionsByMitra = async (mitraId) => {
const sessions = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.expires_at, cs.extended_minutes,
c.display_name AS customer_display_name
FROM chat_sessions cs
@@ -120,14 +120,21 @@ export const rerouteSession = async (sessionId, newMitraId) => {
return session
}
export const listSessions = async ({ page = 1, limit = 20, status } = {}) => {
export const listSessions = async ({ page = 1, limit = 20, status, topic_sensitivity } = {}) => {
const offset = (page - 1) * limit
const conditions = status
? sql`WHERE cs.status = ${status}`
: sql``
let conditions = sql``
if (status && topic_sensitivity) {
conditions = sql`WHERE cs.status = ${status} AND cs.topic_sensitivity = ${topic_sensitivity}`
} else if (status) {
conditions = sql`WHERE cs.status = ${status}`
} else if (topic_sensitivity) {
conditions = sql`WHERE cs.topic_sensitivity = ${topic_sensitivity}`
}
const items = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
FROM chat_sessions cs
@@ -143,7 +150,8 @@ export const listSessions = async ({ page = 1, limit = 20, status } = {}) => {
export const getSessionById = async (sessionId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
@@ -159,7 +167,7 @@ export const getSessionById = async (sessionId) => {
export const getActiveSessionByCustomerWithUnread = async (customerId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
m.display_name AS mitra_display_name,
(SELECT COUNT(*) FROM chat_messages cm
@@ -176,7 +184,7 @@ export const getActiveSessionByCustomerWithUnread = async (customerId) => {
export const getActiveSessionsByMitraWithUnread = async (mitraId) => {
const sessions = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.expires_at, cs.extended_minutes,
c.display_name AS customer_display_name,
(SELECT COUNT(*) FROM chat_messages cm
@@ -194,7 +202,7 @@ export const getActiveSessionsByMitraWithUnread = async (mitraId) => {
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit
const items = await sql`
SELECT cs.id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
m.display_name AS mitra_display_name,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
@@ -215,7 +223,7 @@ export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } =
export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit
const items = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
c.display_name AS customer_display_name,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,

View File

@@ -0,0 +1,90 @@
import { OAuth2Client } from 'google-auth-library'
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'
const getGoogleClientIds = () => {
const raw = process.env.GOOGLE_OAUTH_CLIENT_IDS || ''
return raw.split(',').map((s) => s.trim()).filter(Boolean)
}
const googleClient = new OAuth2Client()
/**
* Verify a Google ID token against Google's JWKS.
* Throws on invalid; returns { sub, email, email_verified, name } on success.
*/
export const verifyGoogleIdToken = async (idToken) => {
const audience = getGoogleClientIds()
if (audience.length === 0) {
throw Object.assign(new Error('GOOGLE_OAUTH_CLIENT_IDS not configured'), {
code: 'OAUTH_MISCONFIGURED', statusCode: 500,
})
}
try {
const ticket = await googleClient.verifyIdToken({ idToken, audience })
const payload = ticket.getPayload()
if (!payload?.sub) {
throw new Error('Google token missing sub')
}
return {
sub: payload.sub,
email: payload.email,
email_verified: payload.email_verified === true,
name: payload.name,
}
} catch (err) {
throw Object.assign(new Error(err.message || 'Invalid Google token'), {
code: 'GOOGLE_TOKEN_INVALID', statusCode: 401,
})
}
}
const appleJwks = jwksClient({
jwksUri: 'https://appleid.apple.com/auth/keys',
cache: true,
cacheMaxAge: 24 * 60 * 60 * 1000,
rateLimit: true,
})
const getApplePublicKey = (kid) => new Promise((resolve, reject) => {
appleJwks.getSigningKey(kid, (err, key) => {
if (err) return reject(err)
resolve(key.getPublicKey())
})
})
/**
* Verify an Apple ID token against Apple's JWKS.
* Throws on invalid; returns { sub, email? } on success.
* Note: Apple only includes `email` on first sign-in; persist it then.
*/
export const verifyAppleIdToken = async (idToken) => {
const audience = process.env.APPLE_SERVICES_ID
if (!audience) {
throw Object.assign(new Error('APPLE_SERVICES_ID not configured'), {
code: 'OAUTH_MISCONFIGURED', statusCode: 500,
})
}
try {
const decoded = jwt.decode(idToken, { complete: true })
if (!decoded?.header?.kid) throw new Error('Apple token missing kid header')
const publicKey = await getApplePublicKey(decoded.header.kid)
const payload = jwt.verify(idToken, publicKey, {
algorithms: ['RS256'],
audience,
issuer: 'https://appleid.apple.com',
})
if (!payload?.sub) throw new Error('Apple token missing sub')
return {
sub: payload.sub,
email: payload.email,
email_verified: payload.email_verified === true || payload.email_verified === 'true',
}
} catch (err) {
throw Object.assign(new Error(err.message || 'Invalid Apple token'), {
code: 'APPLE_TOKEN_INVALID', statusCode: 401,
})
}
}

View File

@@ -0,0 +1,178 @@
import crypto from 'node:crypto'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import { getDb } from '../db/client.js'
const sql = getDb()
const REFRESH_HASH_COST = 10
const getJwtSecret = () => {
const secret = process.env.AUTH_JWT_SECRET
if (!secret || secret.length < 32) {
throw new Error('AUTH_JWT_SECRET is missing or too short (min 32 chars)')
}
return secret
}
const getAccessTokenTtlSeconds = () => {
const raw = process.env.ACCESS_TOKEN_TTL_SECONDS
const n = raw ? parseInt(raw, 10) : 3600
return Number.isFinite(n) && n > 0 ? n : 3600
}
const getRefreshTokenTtlDays = () => {
const raw = process.env.REFRESH_TOKEN_TTL_DAYS
const n = raw ? parseInt(raw, 10) : 30
return Number.isFinite(n) && n > 0 ? n : 30
}
const generateRefreshTokenRaw = () => {
return crypto.randomBytes(32).toString('base64url')
}
const signAccessToken = ({ userType, userId, sessionId }) => {
return jwt.sign(
{ user_type: userType, session_id: sessionId },
getJwtSecret(),
{
algorithm: 'HS256',
expiresIn: getAccessTokenTtlSeconds(),
subject: userId,
},
)
}
/**
* Verify an access token. Returns { userType, userId, sessionId } on success.
* Throws an error with code/statusCode on failure.
*/
export const verifyAccessToken = (token) => {
try {
const decoded = jwt.verify(token, getJwtSecret(), { algorithms: ['HS256'] })
if (!decoded || typeof decoded !== 'object') {
throw new Error('Malformed token')
}
return {
userType: decoded.user_type,
userId: decoded.sub,
sessionId: decoded.session_id,
}
} catch (err) {
const code = err.name === 'TokenExpiredError' ? 'TOKEN_EXPIRED' : 'TOKEN_INVALID'
throw Object.assign(new Error(err.message || 'Invalid token'), {
code, statusCode: 401,
})
}
}
/**
* Create a new auth_sessions row and return tokens.
* deviceInfo is optional JSONB: { user_agent, ip }
*/
export const issueTokens = async ({ userType, userId, deviceInfo }) => {
const refreshRaw = generateRefreshTokenRaw()
const refreshHash = await bcrypt.hash(refreshRaw, REFRESH_HASH_COST)
const ttlDays = getRefreshTokenTtlDays()
const [session] = await sql`
INSERT INTO auth_sessions (user_type, user_id, refresh_token_hash, device_info, expires_at)
VALUES (
${userType}, ${userId}, ${refreshHash}, ${deviceInfo ? sql.json(deviceInfo) : null},
NOW() + (${ttlDays} || ' days')::interval
)
RETURNING id, expires_at
`
const accessToken = signAccessToken({ userType, userId, sessionId: session.id })
const accessTtlSeconds = getAccessTokenTtlSeconds()
return {
access_token: accessToken,
access_token_expires_in: accessTtlSeconds,
refresh_token: `${session.id}.${refreshRaw}`,
refresh_token_expires_at: session.expires_at,
session_id: session.id,
}
}
/**
* Validate a refresh token and rotate it. Returns new token pair.
* Refresh token format: "<session_id>.<raw>"
*/
export const refreshTokens = async ({ refreshToken, deviceInfo }) => {
if (typeof refreshToken !== 'string' || !refreshToken.includes('.')) {
throw Object.assign(new Error('Invalid refresh token'), {
code: 'REFRESH_INVALID', statusCode: 401,
})
}
const [sessionId, raw] = refreshToken.split('.', 2)
const [row] = await sql`
SELECT id, user_type, user_id, refresh_token_hash, expires_at, revoked_at
FROM auth_sessions
WHERE id = ${sessionId}
`
if (!row || row.revoked_at || new Date(row.expires_at) <= new Date()) {
throw Object.assign(new Error('Refresh token expired or revoked'), {
code: 'REFRESH_INVALID', statusCode: 401,
})
}
const ok = await bcrypt.compare(raw, row.refresh_token_hash)
if (!ok) {
throw Object.assign(new Error('Refresh token mismatch'), {
code: 'REFRESH_INVALID', statusCode: 401,
})
}
// Rotate: issue a new raw, update the hash, bump last_used_at and expires_at
const newRaw = generateRefreshTokenRaw()
const newHash = await bcrypt.hash(newRaw, REFRESH_HASH_COST)
const ttlDays = getRefreshTokenTtlDays()
const [updated] = await sql`
UPDATE auth_sessions
SET refresh_token_hash = ${newHash},
last_used_at = NOW(),
expires_at = NOW() + (${ttlDays} || ' days')::interval,
device_info = ${deviceInfo ? sql.json(deviceInfo) : row.device_info ?? null}
WHERE id = ${sessionId}
RETURNING id, user_type, user_id, expires_at
`
const accessToken = signAccessToken({
userType: updated.user_type,
userId: updated.user_id,
sessionId: updated.id,
})
return {
access_token: accessToken,
access_token_expires_in: getAccessTokenTtlSeconds(),
refresh_token: `${updated.id}.${newRaw}`,
refresh_token_expires_at: updated.expires_at,
session_id: updated.id,
user_type: updated.user_type,
user_id: updated.user_id,
}
}
/**
* Revoke a single session (logout).
*/
export const revokeSession = async (sessionId) => {
await sql`
UPDATE auth_sessions SET revoked_at = NOW()
WHERE id = ${sessionId} AND revoked_at IS NULL
`
// Future: SADD session_id into Valkey 'revoked_sessions' with TTL = remaining access token life
}
/**
* Revoke every session for a user (admin ban / force logout everywhere).
*/
export const revokeAllSessionsForUser = async ({ userType, userId }) => {
await sql`
UPDATE auth_sessions SET revoked_at = NOW()
WHERE user_type = ${userType} AND user_id = ${userId} AND revoked_at IS NULL
`
}

View File

@@ -316,6 +316,11 @@ class Chat extends _$Chat {
_cleanup();
break;
case WsMessage.sessionTopicUpdated:
// Customer is never given a visual cue about topic sensitivity;
// message is received for future-proofing but intentionally a no-op.
break;
case WsMessage.error:
break;
}

View File

@@ -42,6 +42,18 @@ class ExtensionStatus {
ExtensionStatus._();
}
/// Session topic sensitivity
enum TopicSensitivity {
regular('regular'),
sensitive('sensitive');
final String value;
const TopicSensitivity(this.value);
static TopicSensitivity fromString(String? v) =>
values.firstWhere((e) => e.value == v, orElse: () => TopicSensitivity.regular);
}
/// WebSocket message types
class WsMessage {
// Auth
@@ -72,6 +84,9 @@ class WsMessage {
static const extensionRequest = 'extension_request';
static const extensionResponse = 'extension_response';
// Topic sensitivity
static const sessionTopicUpdated = 'session_topic_updated';
// Delivery
static const delivered = 'delivered';
static const read = 'read';

View File

@@ -60,12 +60,17 @@ class Pairing extends _$Pairing {
@override
PairingData build() => const PairingInitialData();
Future<void> requestPairing() async {
await _doPairingRequest({});
Future<void> requestPairing({required TopicSensitivity topicSensitivity}) async {
await _doPairingRequest({'topic_sensitivity': topicSensitivity.value});
}
Future<void> requestPairingWithTier({int? durationMinutes, int? price, bool isFreeTrial = false}) async {
final body = <String, dynamic>{};
Future<void> requestPairingWithTier({
int? durationMinutes,
int? price,
bool isFreeTrial = false,
required TopicSensitivity topicSensitivity,
}) async {
final body = <String, dynamic>{'topic_sensitivity': topicSensitivity.value};
if (isFreeTrial) {
body['is_free_trial'] = true;
} else {

View File

@@ -2,20 +2,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/constants.dart';
import '../../../core/pairing/pairing_notifier.dart';
class PricingBottomSheet extends ConsumerWidget {
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
final String? extensionSessionId;
const PricingBottomSheet({super.key, this.extensionSessionId});
/// Required when starting a new pairing. Null when in extension mode.
final TopicSensitivity? topicSensitivity;
const PricingBottomSheet({super.key, this.extensionSessionId, this.topicSensitivity});
/// Show for new pairing (from home screen)
static Future<void> show(BuildContext context) {
static Future<void> show(BuildContext context, {required TopicSensitivity topicSensitivity}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => const PricingBottomSheet(),
builder: (_) => PricingBottomSheet(topicSensitivity: topicSensitivity),
);
}
@@ -124,6 +128,7 @@ class PricingBottomSheet extends ConsumerWidget {
durationMinutes: durationMinutes,
price: price,
isFreeTrial: isFreeTrial,
topicSensitivity: topicSensitivity ?? TopicSensitivity.regular,
);
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../../../core/constants.dart';
class TopicSelectionBottomSheet extends StatelessWidget {
const TopicSelectionBottomSheet({super.key});
/// Shows the sheet and returns the customer's selection (null if cancelled).
static Future<TopicSensitivity?> show(BuildContext context) {
return showModalBottomSheet<TopicSensitivity>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
backgroundColor: Colors.transparent,
builder: (_) => const TopicSelectionBottomSheet(),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PopScope(
canPop: true,
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
padding: EdgeInsets.fromLTRB(
24,
16,
24,
24 + MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 20),
Text(
'Sebelum kita mulai',
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Supaya kami bisa menyiapkan bestie yang tepat untukmu, boleh kami tahu sedikit tentang ceritamu? Tidak ada penilaian di sini — kamu aman bercerita apa pun.',
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey.shade700),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
Text(
'Apakah ceritamu menyentuh topik seperti seksualitas, pornografi, atau penggunaan zat?',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: () => Navigator.of(context).pop(TopicSensitivity.regular),
child: const Text('Topik umum'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: () => Navigator.of(context).pop(TopicSensitivity.sensitive),
child: const Text('Topik sensitif'),
),
),
],
),
const SizedBox(height: 12),
Text(
'Pilihan ini hanya membantu bestie menyiapkan diri. Kamu tetap bisa bercerita apa adanya.',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Batal', style: TextStyle(color: Colors.grey.shade600)),
),
],
),
),
);
}
}

View File

@@ -6,6 +6,7 @@ import '../../core/api/api_client_provider.dart';
import '../../core/chat/unread_notifier.dart';
import '../../core/pairing/pairing_notifier.dart';
import '../chat/widgets/pricing_bottom_sheet.dart';
import '../chat/widgets/topic_selection_bottom_sheet.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@@ -60,6 +61,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
}
}
Future<void> _onStartChatPressed(BuildContext context) async {
final topic = await TopicSelectionBottomSheet.show(context);
if (topic == null || !context.mounted) return;
await PricingBottomSheet.show(context, topicSensitivity: topic);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
@@ -122,7 +129,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: () => PricingBottomSheet.show(context),
onPressed: () => _onStartChatPressed(context),
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
],

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

View File

@@ -32,4 +32,9 @@ class ApiClient {
final response = await _dio.post(path, data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> patch(String path, {Map<String, dynamic>? data}) async {
final response = await _dio.patch(path, data: data);
return response.data as Map<String, dynamic>;
}
}

View File

@@ -35,11 +35,13 @@ class ChatRequestIncomingData extends ChatRequestData {
final String sessionId;
final int? durationMinutes;
final bool? isFreeTrial;
final TopicSensitivity topicSensitivity;
final DateTime? createdAt;
const ChatRequestIncomingData(
this.sessionId, {
this.durationMinutes,
this.isFreeTrial,
this.topicSensitivity = TopicSensitivity.regular,
this.createdAt,
});
}
@@ -99,6 +101,7 @@ class ChatRequest extends _$ChatRequest {
'session_id': sessionId,
'duration_minutes': r['duration_minutes'],
'is_free_trial': r['is_free_trial'],
'topic_sensitivity': r['topic_sensitivity'],
'created_at': r['created_at'],
};
@@ -111,6 +114,7 @@ class ChatRequest extends _$ChatRequest {
sessionId,
durationMinutes: r['duration_minutes'] as int?,
isFreeTrial: r['is_free_trial'] as bool?,
topicSensitivity: TopicSensitivity.fromString(r['topic_sensitivity'] as String?),
createdAt: r['created_at'] != null
? DateTime.tryParse(r['created_at'] as String)
: null,
@@ -200,6 +204,7 @@ class ChatRequest extends _$ChatRequest {
sessionId,
durationMinutes: data['duration_minutes'] as int?,
isFreeTrial: data['is_free_trial'] as bool?,
topicSensitivity: TopicSensitivity.fromString(data['topic_sensitivity'] as String?),
createdAt: data['created_at'] != null
? DateTime.tryParse(data['created_at'] as String)
: null,
@@ -279,6 +284,7 @@ class ChatRequest extends _$ChatRequest {
sessionId,
durationMinutes: next['duration_minutes'] as int?,
isFreeTrial: next['is_free_trial'] as bool?,
topicSensitivity: TopicSensitivity.fromString(next['topic_sensitivity'] as String?),
createdAt: next['created_at'] != null
? DateTime.tryParse(next['created_at'] as String)
: null,

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
@@ -29,6 +30,7 @@ class MitraChatConnectedData extends MitraChatData {
final bool sessionExpired;
final bool sessionClosing;
final Map<String, dynamic>? extensionRequest;
final TopicSensitivity topicSensitivity;
const MitraChatConnectedData({
required this.messages,
@@ -37,6 +39,7 @@ class MitraChatConnectedData extends MitraChatData {
this.sessionExpired = false,
this.sessionClosing = false,
this.extensionRequest,
this.topicSensitivity = TopicSensitivity.regular,
});
MitraChatConnectedData copyWith({
@@ -47,6 +50,7 @@ class MitraChatConnectedData extends MitraChatData {
bool? sessionClosing,
Map<String, dynamic>? extensionRequest,
bool clearExtensionRequest = false,
TopicSensitivity? topicSensitivity,
}) {
return MitraChatConnectedData(
messages: messages ?? this.messages,
@@ -55,6 +59,7 @@ class MitraChatConnectedData extends MitraChatData {
sessionExpired: sessionExpired ?? this.sessionExpired,
sessionClosing: sessionClosing ?? this.sessionClosing,
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
);
}
}
@@ -119,6 +124,7 @@ class MitraChat extends _$MitraChat {
}
final isClosing = sessionStatus == SessionStatus.closing;
final sessionTopic = TopicSensitivity.fromString(sessionData?['topic_sensitivity'] as String?);
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
final messagesData = response['data'] as List<dynamic>;
@@ -153,7 +159,7 @@ class MitraChat extends _$MitraChat {
'session_id': sessionId,
}));
state = MitraChatConnectedData(messages: messages, sessionClosing: isClosing);
state = MitraChatConnectedData(messages: messages, sessionClosing: isClosing, topicSensitivity: sessionTopic);
} catch (e) {
state = const MitraChatErrorData('Gagal terhubung ke chat.');
}
@@ -288,6 +294,31 @@ class MitraChat extends _$MitraChat {
case WsMessage.sessionCompleted:
_cleanup();
break;
case WsMessage.sessionTopicUpdated:
final newValue = TopicSensitivity.fromString(data['topic_sensitivity'] as String?);
state = current.copyWith(topicSensitivity: newValue);
break;
}
}
/// Flip the session's topic sensitivity. Returns error code on failure, null on success.
Future<String?> flipTopic(String sessionId, TopicSensitivity toValue) async {
try {
final response = await _apiClient.patch(
'/api/shared/chat/sessions/$sessionId/topic',
data: {'topic_sensitivity': toValue.value},
);
final updated = TopicSensitivity.fromString(response['data']?['topic_sensitivity'] as String?);
final current = state;
if (current is MitraChatConnectedData) {
state = current.copyWith(topicSensitivity: updated);
}
return null;
} on DioException catch (e) {
return e.response?.data?['error']?['code'] as String? ?? 'FLIP_FAILED';
} catch (_) {
return 'FLIP_FAILED';
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/api_client_provider.dart';
class SensitivityConfig {
final bool flipConfirmationEnabled;
final bool oneWayLatch;
const SensitivityConfig({
required this.flipConfirmationEnabled,
required this.oneWayLatch,
});
static const defaults = SensitivityConfig(
flipConfirmationEnabled: true,
oneWayLatch: false,
);
}
/// Fetches the sensitivity config once and caches it for the app lifetime.
/// Falls back to defaults if the request fails.
final sensitivityConfigProvider = FutureProvider<SensitivityConfig>((ref) async {
try {
final response = await ref.read(apiClientProvider).get('/api/shared/sensitivity');
final data = response['data'] as Map<String, dynamic>;
return SensitivityConfig(
flipConfirmationEnabled: data['flip_confirmation_enabled'] as bool? ?? true,
oneWayLatch: data['one_way_latch'] as bool? ?? false,
);
} catch (_) {
return SensitivityConfig.defaults;
}
});

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../chat_request_notifier.dart';
import '../../constants.dart';
import '../../../router.dart';
import 'sensitivity_badge.dart';
import 'sensitivity_theme.dart';
class ChatRequestOverlay extends ConsumerStatefulWidget {
final Widget child;
@@ -132,12 +135,17 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
: data.durationMinutes != null
? '${data.durationMinutes} Menit'
: '';
final isSensitive = data.topicSensitivity == TopicSensitivity.sensitive;
final theme = SensitivityTheme.of(data.topicSensitivity);
return Container(
decoration: const BoxDecoration(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
border: isSensitive
? Border(top: BorderSide(color: theme.badgeBg, width: 4))
: null,
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
),
child: SafeArea(
top: false,
@@ -168,6 +176,10 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
'Durasi: $durationText',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
if (isSensitive) ...[
const SizedBox(height: 8),
SensitivityBadge(sensitivity: data.topicSensitivity, fontSize: 12),
],
const SizedBox(height: 8),
const Text(
'Seorang customer ingin curhat denganmu.',

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../../constants.dart';
import 'sensitivity_theme.dart';
/// Small pill-style badge used across the mitra app to indicate a sensitive session.
/// Renders nothing for regular sessions.
class SensitivityBadge extends StatelessWidget {
final TopicSensitivity sensitivity;
final double fontSize;
final EdgeInsets padding;
const SensitivityBadge({
super.key,
required this.sensitivity,
this.fontSize = 11,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
});
@override
Widget build(BuildContext context) {
if (sensitivity != TopicSensitivity.sensitive) return const SizedBox.shrink();
const theme = SensitivityTheme.sensitive;
return Container(
padding: padding,
decoration: BoxDecoration(
color: theme.badgeBg,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warning_amber_rounded, size: fontSize + 2, color: theme.badgeFg),
const SizedBox(width: 4),
Text(
'Topik sensitif',
style: TextStyle(
color: theme.badgeFg,
fontSize: fontSize,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../../constants.dart';
/// Color tokens used by the mitra chat UI to differentiate regular vs sensitive sessions.
class SensitivityTheme {
final Color bgTint;
final Color accent;
final Color banner;
final Color badgeBg;
final Color badgeFg;
const SensitivityTheme({
required this.bgTint,
required this.accent,
required this.banner,
required this.badgeBg,
required this.badgeFg,
});
static const regular = SensitivityTheme(
bgTint: Color(0xFFF5D0D6),
accent: Color(0xFFBE7C8A),
banner: Color(0xFFC4868F),
badgeBg: Color(0xFFBE7C8A),
badgeFg: Colors.white,
);
static const sensitive = SensitivityTheme(
bgTint: Color(0xFFFFE8A3),
accent: Color(0xFFB88900),
banner: Color(0xFFE0A500),
badgeBg: Color(0xFFF7B500),
badgeFg: Color(0xFF3D2A00),
);
static SensitivityTheme of(TopicSensitivity s) =>
s == TopicSensitivity.sensitive ? sensitive : regular;
}

View File

@@ -42,6 +42,18 @@ class ExtensionStatus {
ExtensionStatus._();
}
/// Session topic sensitivity
enum TopicSensitivity {
regular('regular'),
sensitive('sensitive');
final String value;
const TopicSensitivity(this.value);
static TopicSensitivity fromString(String? v) =>
values.firstWhere((e) => e.value == v, orElse: () => TopicSensitivity.regular);
}
/// WebSocket message types
class WsMessage {
// Auth
@@ -72,6 +84,9 @@ class WsMessage {
static const extensionRequest = 'extension_request';
static const extensionResponse = 'extension_response';
// Topic sensitivity
static const sessionTopicUpdated = 'session_topic_updated';
// Delivery
static const delivered = 'delivered';
static const read = 'read';

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/chat/widgets/sensitivity_badge.dart';
import '../../../core/constants.dart';
class MitraChatHistoryScreen extends ConsumerStatefulWidget {
const MitraChatHistoryScreen({super.key});
@@ -53,9 +55,19 @@ class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen>
final duration = s['duration_minutes'] as int?;
final closureMsg = s['mitra_closure_message'] as String?;
final topic = TopicSensitivity.fromString(s['topic_sensitivity'] as String?);
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: Text(customerName),
title: Row(
children: [
Flexible(child: Text(customerName, overflow: TextOverflow.ellipsis)),
if (topic == TopicSensitivity.sensitive) ...[
const SizedBox(width: 8),
SensitivityBadge(sensitivity: topic, fontSize: 10),
],
],
),
subtitle: Text([
if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}',
if (duration != null) '$duration menit',

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/chat/widgets/sensitivity_badge.dart';
import '../../../core/chat/widgets/sensitivity_theme.dart';
import '../../../core/constants.dart';
class MitraChatTranscriptScreen extends ConsumerStatefulWidget {
@@ -15,6 +17,7 @@ class MitraChatTranscriptScreen extends ConsumerStatefulWidget {
class _MitraChatTranscriptScreenState extends ConsumerState<MitraChatTranscriptScreen> {
List<Map<String, dynamic>> _messages = [];
List<Map<String, dynamic>> _closures = [];
TopicSensitivity _topicSensitivity = TopicSensitivity.regular;
bool _loading = true;
@override
@@ -26,11 +29,16 @@ class _MitraChatTranscriptScreenState extends ConsumerState<MitraChatTranscriptS
Future<void> _loadTranscript() async {
try {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
final data = response['data'] as Map<String, dynamic>;
final results = await Future.wait([
api.get('/api/shared/chat/${widget.sessionId}/transcript'),
api.get('/api/shared/chat/${widget.sessionId}/info'),
]);
final data = results[0]['data'] as Map<String, dynamic>;
final info = results[1]['data'] as Map<String, dynamic>?;
setState(() {
_messages = (data['messages'] as List<dynamic>).cast<Map<String, dynamic>>();
_closures = (data['closures'] as List<dynamic>).cast<Map<String, dynamic>>();
_topicSensitivity = TopicSensitivity.fromString(info?['topic_sensitivity'] as String?);
_loading = false;
});
} catch (_) {
@@ -40,8 +48,21 @@ class _MitraChatTranscriptScreenState extends ConsumerState<MitraChatTranscriptS
@override
Widget build(BuildContext context) {
final isSensitive = _topicSensitivity == TopicSensitivity.sensitive;
return Scaffold(
appBar: AppBar(title: const Text('Transkrip Chat')),
backgroundColor: isSensitive ? SensitivityTheme.sensitive.bgTint : null,
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Transkrip Chat'),
if (isSensitive) ...[
const SizedBox(width: 8),
SensitivityBadge(sensitivity: _topicSensitivity, fontSize: 11),
],
],
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(

View File

@@ -4,11 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/chat/mitra_chat_notifier.dart';
import '../../../core/chat/extension_notifier.dart';
import '../../../core/chat/sensitivity_config_provider.dart';
import '../../../core/chat/widgets/sensitivity_badge.dart';
import '../../../core/chat/widgets/sensitivity_theme.dart';
import '../../../core/constants.dart';
// Chat theme colors
const _kUserBubbleColor = Color(0xFFD4929A);
const _kBgTint = Color(0xFFF5D0D6);
const _kBannerColor = Color(0xFFC4868F);
const _kAccentPink = Color(0xFFBE7C8A);
@@ -99,6 +101,10 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
}
});
final currentSensitivity = chatState is MitraChatConnectedData
? chatState.topicSensitivity
: TopicSensitivity.regular;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
@@ -111,6 +117,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
),
title: Text(widget.customerName),
actions: [
if (chatState is MitraChatConnectedData) _buildTopicToggle(chatState),
if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null)
Padding(
padding: const EdgeInsets.only(right: 16),
@@ -126,10 +133,120 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
),
],
),
body: _buildBody(chatState, extState),
body: Column(
children: [
if (currentSensitivity == TopicSensitivity.sensitive)
_buildSensitivityHeader(),
Expanded(child: _buildBody(chatState, extState)),
],
),
);
}
Widget _buildSensitivityHeader() {
const theme = SensitivityTheme.sensitive;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
color: theme.badgeBg,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: theme.badgeFg),
const SizedBox(width: 6),
Text(
'Topik sensitif',
style: TextStyle(
color: theme.badgeFg,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildTopicToggle(MitraChatConnectedData state) {
final configAsync = ref.watch(sensitivityConfigProvider);
final config = configAsync.value ?? SensitivityConfig.defaults;
final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive;
final locked = config.oneWayLatch && isSensitive;
return Tooltip(
message: locked
? 'Sesi sudah terkunci sebagai topik sensitif'
: isSensitive
? 'Tandai sebagai topik umum'
: 'Tandai sebagai topik sensitif',
child: IconButton(
icon: Icon(
isSensitive ? Icons.flag : Icons.outlined_flag,
color: isSensitive ? SensitivityTheme.sensitive.badgeBg : Colors.grey.shade600,
),
onPressed: locked ? null : () => _onTopicTogglePressed(state, config),
),
);
}
Future<void> _onTopicTogglePressed(
MitraChatConnectedData state,
SensitivityConfig config,
) async {
final toValue = state.topicSensitivity == TopicSensitivity.sensitive
? TopicSensitivity.regular
: TopicSensitivity.sensitive;
if (config.flipConfirmationEnabled) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
toValue == TopicSensitivity.sensitive
? 'Tandai sesi ini sebagai sensitif?'
: 'Tandai sesi ini sebagai topik umum?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Tandai'),
),
],
),
);
if (confirmed != true || !mounted) return;
}
final err = await ref
.read(mitraChatProvider.notifier)
.flipTopic(widget.sessionId, toValue);
if (!mounted) return;
if (err != null) {
final msg = err == 'SENSITIVITY_LATCHED'
? 'Sesi sudah ditandai sensitif dan tidak bisa dikembalikan.'
: err == 'SESSION_NOT_ACTIVE'
? 'Sesi sudah berakhir.'
: 'Gagal mengubah topik. Coba lagi.';
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
toValue == TopicSensitivity.sensitive
? 'Sesi ditandai sensitif'
: 'Sesi ditandai topik umum',
),
duration: const Duration(seconds: 2),
),
);
}
}
Widget _buildBody(MitraChatData chatState, ExtensionData extState) {
if (chatState is MitraChatConnectingData) {
return const Center(child: CircularProgressIndicator());
@@ -154,12 +271,14 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
return _buildGoodbyeView(extState);
}
final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint;
return Stack(
children: [
// Background pattern
Positioned.fill(
child: Container(
color: _kBgTint,
color: bgTint,
child: Image.asset(
'assets/images/chat_pattern.png',
repeat: ImageRepeat.repeat,
@@ -328,8 +447,12 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
final duration = request['duration_minutes'] as int?;
final extensionId = request['extension_id'] as String?;
final isResponding = extState is ExtensionRespondingData;
final topic = TopicSensitivity.fromString(request['topic_sensitivity'] as String?);
final isSensitive = topic == TopicSensitivity.sensitive;
return Center(
return Container(
color: isSensitive ? SensitivityTheme.sensitive.bgTint : null,
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
@@ -338,6 +461,10 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
const Icon(Icons.timer, size: 64, color: Colors.orange),
const SizedBox(height: 16),
const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
if (isSensitive) ...[
const SizedBox(height: 8),
SensitivityBadge(sensitivity: topic),
],
const SizedBox(height: 8),
Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center),
const SizedBox(height: 24),
@@ -371,6 +498,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
],
),
),
),
);
}

View File

@@ -0,0 +1,513 @@
# Phase 3.3 Implementation Plan: Session Topic Sensitivity
## Summary of Clarified Requirements
| Topic | Decision |
|---|---|
| Customer selection UX | Required bottom sheet after "Mulai Curhat", before pricing; two buttons (not toggle) |
| Customer flag mutability | Locked for session duration — customer cannot change |
| Mitra flag mutability | Can flip mid-session via app-bar toggle |
| Flip confirmation dialog | Default `true`, disableable via `app_config` key `sensitive_flip_confirmation_enabled` |
| One-way latch | Default `false`, enableable via `app_config` key `sensitive_flag_one_way_latch` |
| Visual cue for mitra | Label "Topik sensitif" + warning-yellow doodle background |
| Customer visual cue | None — customer UI stays pink always |
| Storage | `chat_sessions.topic_sensitivity` enum + mirrored on `chat_requests`-like flow (actual rows live in `chat_sessions` before acceptance; see Section 1.1) |
| Audit trail | New `session_sensitivity_log` table — logs every mitra flip |
| WS event to customer | Sent but silently ignored client-side (future-proofing) |
| Extension | Shows current flag; flag carries over unchanged on acceptance |
| Control center | Sensitive stats panel + filter on sessions page + 2 new config keys |
| Pricing | Unchanged — schema leaves room for future differentiation |
| Push notification content | Stays generic — label only surfaces in overlay |
| Multi-device mitra sync | Out of scope |
| Auto-moderation | Out of scope |
---
## Data Model Note
After reviewing the codebase, `chat_requests` is NOT a separate table — pairing requests live as rows in `chat_sessions` with `status` transitions (`SEARCHING``PENDING_ACCEPTANCE``ACTIVE` → ...). Mitra notifications are tracked in `chat_request_notifications`. Therefore, **one column on `chat_sessions` is sufficient** — no mirroring needed.
---
## Work Stream 1: Backend — Schema, Services, Routes
### 1.1 DB Migration
**File:** [backend/src/db/migrate.js](backend/src/db/migrate.js)
```sql
-- New column on chat_sessions
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS topic_sensitivity VARCHAR(16) NOT NULL DEFAULT 'regular';
-- Optional index if CC filter needs it
CREATE INDEX IF NOT EXISTS idx_chat_sessions_topic_sensitivity
ON chat_sessions (topic_sensitivity);
-- New audit table
CREATE TABLE IF NOT EXISTS session_sensitivity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
changed_by_mitra_id UUID NOT NULL REFERENCES mitras(id),
from_value VARCHAR(16) NOT NULL,
to_value VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_session_sensitivity_log_session
ON session_sensitivity_log (session_id);
```
**Seed new `app_config` keys** (in the same migration or the seed script):
```sql
INSERT INTO app_config (key, value) VALUES
('sensitive_flip_confirmation_enabled', 'true'::jsonb),
('sensitive_flag_one_way_latch', 'false'::jsonb)
ON CONFLICT (key) DO NOTHING;
```
### 1.2 Constants
**File:** [backend/src/constants.js](backend/src/constants.js)
```js
const TopicSensitivity = Object.freeze({
REGULAR: 'regular',
SENSITIVE: 'sensitive',
});
const WsMessage = Object.freeze({
// ... existing
SESSION_TOPIC_UPDATED: 'session_topic_updated',
});
```
### 1.3 Config Service
**File:** [backend/src/services/config.service.js](backend/src/services/config.service.js)
Add:
- `getSensitiveFlipConfirmationEnabled()` → bool (default `true`)
- `getSensitiveFlagOneWayLatch()` → bool (default `false`)
- `setSensitiveConfig({ flipConfirmationEnabled, oneWayLatch })` — used by control center
### 1.4 Pairing Service — Accept Topic Flag From Customer
**File:** [backend/src/services/pairing.service.js](backend/src/services/pairing.service.js)
- `createPairingRequest({ customerId, durationMinutes, isFreeTrial, topicSensitivity })` — new param, validated against `TopicSensitivity` enum; defaults to `regular` if omitted.
- Insert `topic_sensitivity` column when creating the `chat_sessions` row.
- When broadcasting the `chat_request` WS message to candidate mitras, include `topic_sensitivity` in the payload (alongside `duration_minutes`, `is_free_trial`).
### 1.5 New Service: Sensitivity Service
**New file:** `backend/src/services/sensitivity.service.js`
Exports:
- `flipSessionSensitivity({ sessionId, mitraId, toValue })`
- Load session; verify ownership (mitra_id matches).
- Verify session status is `ACTIVE` or `EXTENDING` (not flippable if `CLOSING`, `COMPLETED`, etc.).
- Read `sensitive_flag_one_way_latch` from config. If `true` and current value is `sensitive`, reject with 409.
- No-op if `fromValue === toValue`.
- Transaction: UPDATE `chat_sessions.topic_sensitivity`, INSERT `session_sensitivity_log` row.
- Broadcast WS message `session_topic_updated` to both participants: `{ session_id, topic_sensitivity, changed_at }`.
- Return updated session.
- `getSessionSensitivityLog(sessionId)` — used by control center session detail (future).
### 1.6 Extension Service — Expose Current Flag
**File:** [backend/src/services/extension.service.js](backend/src/services/extension.service.js)
- `requestExtension` does not change the flag; flag carries over unchanged.
- When broadcasting the extension request WS message to the mitra, include `topic_sensitivity` from the session row.
### 1.7 New Routes
**File:** [backend/src/routes/public/shared.chat.routes.js](backend/src/routes/public/shared.chat.routes.js) (or the existing shared chat routes file — confirm name at implementation)
| Method | Path | Purpose | Auth |
|---|---|---|---|
| `PATCH` | `/api/shared/chat/sessions/:sessionId/topic` | Mitra flips flag | Firebase (mitra) |
**Body:** `{ topic_sensitivity: 'regular' \| 'sensitive' }`
**Response:** `{ session_id, topic_sensitivity, changed_at }`
### 1.8 Client Chat Request Route — Accept Topic Param
**File:** [backend/src/routes/public/client.chat.routes.js](backend/src/routes/public/client.chat.routes.js) — `POST /api/client/chat/request`
Extend request body schema:
```js
{
duration_minutes: Joi/ajv,
is_free_trial: bool,
topic_sensitivity: enum('regular', 'sensitive') // required
}
```
Pass through to `createPairingRequest`.
### 1.9 Internal Routes — Config + Stats
**File:** [backend/src/routes/internal/config.routes.js](backend/src/routes/internal/config.routes.js)
Add:
- `GET /internal/config/sensitivity``{ flip_confirmation_enabled, one_way_latch }`
- `PATCH /internal/config/sensitivity` → accept same shape
**File:** [backend/src/routes/internal/session.routes.js](backend/src/routes/internal/session.routes.js)
- Extend session list query to accept `topic_sensitivity` filter param (`all` / `regular` / `sensitive`).
- Extend session detail response to include `topic_sensitivity` and recent `session_sensitivity_log` entries.
**File:** [backend/src/routes/internal/dashboard.routes.js](backend/src/routes/internal/dashboard.routes.js) (or wherever dashboard stats live)
- Add `sensitive_session_stats` to the dashboard payload: `{ total_sessions, sensitive_count, sensitive_percent }` over the last N days (match existing stat windows).
### 1.10 WebSocket — New Message Type
**File:** [backend/src/plugins/websocket.js](backend/src/plugins/websocket.js)
No plugin change needed if broadcast is done via existing `sendToSessionParticipant` helper — just a new `WsMessage.SESSION_TOPIC_UPDATED` constant.
---
## Work Stream 2: client_app — Topic Selection
### 2.1 New Widget: Topic Selection Bottom Sheet
**New file:** `client_app/lib/features/chat/widgets/topic_selection_bottom_sheet.dart`
A non-dismissible `DraggableScrollableSheet` (or `showModalBottomSheet` with `isDismissible: false` and `enableDrag: false`) containing:
- Title, body, sub-question (copy as in PRD Section 1)
- Two equal-width buttons: "Topik umum" (primary) + "Topik sensitif" (secondary style, equal height)
- Helper line below buttons
- System back / back button cancels and returns to home
**Return:** `Future<TopicSensitivity?>` — null if cancelled, otherwise the selected enum.
### 2.2 Topic Sensitivity Enum
**New file:** `client_app/lib/core/constants/topic_sensitivity.dart`
```dart
enum TopicSensitivity {
regular('regular'),
sensitive('sensitive');
final String value;
const TopicSensitivity(this.value);
static TopicSensitivity fromString(String v) =>
values.firstWhere((e) => e.value == v, orElse: () => TopicSensitivity.regular);
}
```
### 2.3 Home Screen — Wire Bottom Sheet Into Flow
**File:** [client_app/lib/features/home/home_screen.dart](client_app/lib/features/home/home_screen.dart)
Current "Mulai Curhat" → opens pricing sheet directly.
New flow:
1. Tap "Mulai Curhat"
2. Show `TopicSelectionBottomSheet` → await result
3. If result is null → no-op
4. If result is non-null → open existing `PricingBottomSheet`, passing selected topic as param
### 2.4 Pricing Bottom Sheet — Carry Topic
**File:** [client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart](client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart)
- Accept `TopicSensitivity topicSensitivity` constructor param.
- When firing the pairing request (via `PairingNotifier`), pass through `topic_sensitivity` to the request body.
### 2.5 Pairing Notifier — Pass Topic to Backend
**File:** [client_app/lib/core/pairing/pairing_notifier.dart](client_app/lib/core/pairing/pairing_notifier.dart)
- Extend `startPairing({ int durationMinutes, bool isFreeTrial, required TopicSensitivity topicSensitivity })` to include `topic_sensitivity` in the API call body.
### 2.6 No Customer UI Changes Beyond Selection
- Chat screen remains pink doodle — **no conditional coloring**.
- WS listener for `session_topic_updated` is wired but **silently no-ops** (so the server-side event doesn't cause errors).
**File:** [client_app/lib/core/chat/chat_notifier.dart](client_app/lib/core/chat/chat_notifier.dart) (or wherever WS messages are dispatched)
Add a switch case for `session_topic_updated` that logs and returns — no state update.
---
## Work Stream 3: mitra_app — Flag Display + Mid-Session Flip
### 3.1 Shared Enum + Widget: Sensitivity Badge
**New file:** `mitra_app/lib/core/constants/topic_sensitivity.dart` — same shape as client_app version.
**New file:** `mitra_app/lib/core/chat/widgets/sensitivity_badge.dart`
A small pill widget rendering "Topik sensitif" on yellow background. Configurable size. Reused across overlay, chat header, extension card, history list.
### 3.2 Chat Request Overlay — Label + Color
**File:** [mitra_app/lib/core/chat/widgets/chat_request_overlay.dart](mitra_app/lib/core/chat/widgets/chat_request_overlay.dart)
- Extend `ChatRequestIncomingData` (in [mitra_app/lib/core/chat/chat_request_notifier.dart](mitra_app/lib/core/chat/chat_request_notifier.dart)) with `TopicSensitivity topicSensitivity` field.
- Parse from WS `chat_request` message payload (`topic_sensitivity`).
- When sensitive: show yellow accent (e.g., left-border or top-strip) + `SensitivityBadge` near session metadata.
### 3.3 Chat Request Notifier — Parse Topic
**File:** [mitra_app/lib/core/chat/chat_request_notifier.dart](mitra_app/lib/core/chat/chat_request_notifier.dart)
Extend `ChatRequestIncomingData` constructor + WS handler to populate `topicSensitivity`.
### 3.4 Mitra Chat Notifier — Track + Flip Topic
**File:** [mitra_app/lib/core/chat/mitra_chat_notifier.dart](mitra_app/lib/core/chat/mitra_chat_notifier.dart)
- Add `TopicSensitivity topicSensitivity` to the chat state (populated from session fetch on connect).
- Handle incoming WS `session_topic_updated` → update state (authoritative from backend — handles the case where mitra flipped on another device in the future).
- New method `flipTopic()`:
- Read `sensitive_flip_confirmation_enabled` from app_config provider (fetched on app start or via dedicated provider)
- Show confirmation dialog if enabled
- Call `PATCH /api/shared/chat/sessions/:sessionId/topic` via existing `ApiClient`
- Show toast on success; show error dialog on failure (e.g., one-way latch violation → "Sesi sudah ditandai sensitif dan tidak bisa diubah kembali.")
### 3.5 Mitra Chat Screen — Yellow Doodle + Toggle
**File:** [mitra_app/lib/features/chat/screens/mitra_chat_screen.dart](mitra_app/lib/features/chat/screens/mitra_chat_screen.dart)
- Conditional background: watch `topicSensitivity` from `mitraChatNotifier`; swap doodle asset (pink ↔ yellow).
- Header banner: persistent small strip showing "Topik sensitif" label when sensitive.
- App-bar toggle icon (e.g., `Icons.flag` / `Icons.flag_outlined`) wired to `flipTopic()`.
- If `one_way_latch` is enabled and current is `sensitive`, disable the icon and show tooltip "Sesi sudah terkunci sensitif."
### 3.6 Doodle Assets
**New files:** `mitra_app/assets/doodle_yellow.png` (or SVG) — warning-yellow version of existing pink doodle pattern.
- Exact yellow hex: proposed `#FFC107` (Material amber) or `#F7B500` — confirm with designer at implementation. Choose whichever contrasts best with white text/dark badges.
- Update `mitra_app/pubspec.yaml` assets section.
### 3.7 Extension UI — Current Flag
**File:** [mitra_app/lib/features/chat/widgets/extension_request_card.dart](mitra_app/lib/features/chat/widgets/extension_request_card.dart) (or wherever the extension request UI lives)
- Read `topicSensitivity` from the extension WS payload / session state.
- If sensitive: yellow accent + `SensitivityBadge` on the extension card.
### 3.8 Chat History List + Transcript
**File:** [mitra_app/lib/features/chat/screens/chat_history_screen.dart](mitra_app/lib/features/chat/screens/chat_history_screen.dart) (confirm name)
- List row: add `SensitivityBadge` when session is sensitive.
- Transcript detail: yellow doodle background for sensitive sessions (pink otherwise).
### 3.9 Mitra API Client
**File:** [mitra_app/lib/core/network/api_client.dart](mitra_app/lib/core/network/api_client.dart) (confirm name)
Add:
```dart
Future<Session> flipSessionTopic(String sessionId, TopicSensitivity to);
```
---
## Work Stream 4: control_center — Config + Stats + Filter
### 4.1 Settings Page — New Sensitivity Section
**File:** [control_center/src/pages/settings/SettingsPage.jsx](control_center/src/pages/settings/SettingsPage.jsx)
Add new section "Sensitivitas Topik":
- Checkbox: "Aktifkan dialog konfirmasi saat Mitra menandai topik sensitif" (`sensitive_flip_confirmation_enabled`)
- Checkbox: "Kunci searah — Mitra tidak bisa membatalkan tanda topik sensitif" (`sensitive_flag_one_way_latch`)
Wire to new endpoints: `GET/PATCH /internal/config/sensitivity`.
### 4.2 Sessions Page — Filter
**File:** [control_center/src/pages/sessions/SessionsPage.jsx](control_center/src/pages/sessions/SessionsPage.jsx)
- Add dropdown filter: "Semua / Umum / Sensitif".
- Pass `topic_sensitivity` query param to session list endpoint.
- Add column "Topik" with badge (green "Umum" / yellow "Sensitif").
### 4.3 Session Detail — Show Audit Trail
**File:** `control_center/src/pages/sessions/SessionDetailPage.jsx` (confirm name)
- Show current `topic_sensitivity`.
- Show compact timeline of `session_sensitivity_log` entries: "Mitra X menandai topik sebagai sensitif pada HH:MM".
### 4.4 Dashboard — Sensitive Stats Panel
**File:** [control_center/src/pages/dashboard/DashboardPage.jsx](control_center/src/pages/dashboard/DashboardPage.jsx)
- New card: "Sesi Sensitif" with count + percentage over selected date range.
### 4.5 Mitra Activity — Per-Mitra Sensitive Breakdown
**Backend file:** [backend/src/services/mitra-activity.service.js](backend/src/services/mitra-activity.service.js)
Extend `getMitraActivitySummary` SQL to join `chat_request_notifications``chat_sessions` on `session_id` and group per-mitra, returning additional columns:
- `sensitive_total` — count of notifications where joined session had `topic_sensitivity = 'sensitive'`
- `sensitive_accepted` — count of those with `response = 'accepted'`
- `sensitive_rejected` — count with `response = 'rejected'`
- `sensitive_acceptance_rate``sensitive_accepted / sensitive_total` (null if `sensitive_total = 0`)
This is a pure join — no new column needed on `chat_request_notifications` because the flag is derivable from the parent session row.
**Backend file:** [backend/src/routes/internal/mitra-activity.routes.js](backend/src/routes/internal/mitra-activity.routes.js)
`GET /internal/mitra-activity/summary` response includes the new fields above.
**Control center file:** [control_center/src/pages/mitra-activity/MitraActivityPage.jsx](control_center/src/pages/mitra-activity/MitraActivityPage.jsx)
Extend summary table with additional columns (may need a sub-grouping to keep it readable):
| Mitra | Total | Accepted | Rejected | Missed | Ignored | Rate (%) | Avg Response (s) | **Sensitive Total** | **Sensitive Accepted** | **Sensitive Rate (%)** |
Optionally: color the "Sensitive Rate" cell red when significantly lower than the overall rate — useful for spotting mitras who accept regular requests but consistently skip sensitive ones (potential QC signal).
Optional detail view: on the detail log table, add a "Topik" column with the session's sensitivity badge.
---
## 5. Implementation Order
| Step | What | Apps Affected | Dependencies |
|---|---|---|---|
| **Work Stream 1: Backend** | | | |
| 1 | DB migration: `topic_sensitivity` column, `session_sensitivity_log` table, 2 new `app_config` keys | Backend | None |
| 2 | Constants: `TopicSensitivity`, `WsMessage.SESSION_TOPIC_UPDATED` | Backend | None |
| 3 | Config service: getters + setter for sensitivity flags | Backend | Step 1 |
| 4 | Pairing service: accept + persist + broadcast `topic_sensitivity` | Backend | Steps 12 |
| 5 | New sensitivity service: `flipSessionSensitivity`, `getSessionSensitivityLog` | Backend | Steps 14 |
| 6 | Extension service: include `topic_sensitivity` in WS payload | Backend | Step 4 |
| 7 | New route: `PATCH /api/shared/chat/sessions/:sessionId/topic` | Backend | Step 5 |
| 8 | Extend `POST /api/client/chat/request` schema | Backend | Step 4 |
| 9 | Internal routes: `/internal/config/sensitivity`, filter on sessions, dashboard sensitive stats | Backend | Step 3, 5 |
| **Work Stream 2: client_app** | | | |
| 10 | `TopicSensitivity` enum | client_app | None |
| 11 | `TopicSelectionBottomSheet` widget | client_app | Step 10 |
| 12 | Home screen: new flow (topic sheet → pricing) | client_app | Step 11 |
| 13 | Pricing sheet + PairingNotifier: accept + forward flag | client_app | Steps 10, 12 |
| 14 | ChatNotifier: silently handle `session_topic_updated` WS | client_app | None |
| **Work Stream 3: mitra_app** | | | |
| 15 | `TopicSensitivity` enum + `SensitivityBadge` widget + yellow doodle asset | mitra_app | None |
| 16 | ChatRequestNotifier + overlay: parse topic, show badge + yellow accent | mitra_app | Step 15 |
| 17 | MitraChatNotifier: track topic, handle `session_topic_updated`, `flipTopic()` | mitra_app | Step 15, backend step 7 |
| 18 | Mitra chat screen: yellow doodle + header + app-bar toggle + config-driven dialog/latch | mitra_app | Step 17 |
| 19 | Extension card: show current flag | mitra_app | Step 15, backend step 6 |
| 20 | History list + transcript: badge + yellow doodle | mitra_app | Step 15 |
| **Work Stream 4: control_center** | | | |
| 21 | Settings page: sensitivity config section | control_center | Backend step 9 |
| 22 | Sessions list: filter + column | control_center | Backend step 9 |
| 23 | Session detail: audit trail timeline | control_center | Backend step 9 |
| 24 | Dashboard: sensitive stats card | control_center | Backend step 9 |
| 25 | Mitra Activity: extend summary SQL + API + table with sensitive columns | Backend + control_center | Backend step 1 |
| **Testing** | | | |
| 26 | E2E: customer selects sensitive → mitra sees label + yellow → mitra flips → audit + stats reflect | All | All above |
---
## 6. New Files
| File | Purpose |
|---|---|
| `backend/src/services/sensitivity.service.js` | Flip + audit + one-way-latch enforcement |
| `client_app/lib/core/constants/topic_sensitivity.dart` | Enum |
| `client_app/lib/features/chat/widgets/topic_selection_bottom_sheet.dart` | Customer topic-selection UI |
| `mitra_app/lib/core/constants/topic_sensitivity.dart` | Enum |
| `mitra_app/lib/core/chat/widgets/sensitivity_badge.dart` | Reusable badge |
| `mitra_app/assets/doodle_yellow.png` | Yellow doodle for sensitive sessions |
## 7. Modified Files (Primary)
| File | Change |
|---|---|
| `backend/src/db/migrate.js` | Migration + seed |
| `backend/src/constants.js` | `TopicSensitivity` + `WsMessage.SESSION_TOPIC_UPDATED` |
| `backend/src/services/config.service.js` | Getters/setter |
| `backend/src/services/pairing.service.js` | Accept + persist + broadcast topic |
| `backend/src/services/extension.service.js` | Include topic in WS payload |
| `backend/src/routes/public/client.chat.routes.js` | Request body schema |
| `backend/src/routes/public/shared.chat.routes.js` | New PATCH topic route |
| `backend/src/routes/internal/config.routes.js` | GET/PATCH sensitivity config |
| `backend/src/routes/internal/session.routes.js` | Filter + audit in detail |
| `backend/src/routes/internal/dashboard.routes.js` | Sensitive stats |
| `client_app/lib/features/home/home_screen.dart` | New pre-pricing step |
| `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart` | Accept + forward topic |
| `client_app/lib/core/pairing/pairing_notifier.dart` | API body |
| `client_app/lib/core/chat/chat_notifier.dart` | No-op WS handler |
| `mitra_app/lib/core/chat/chat_request_notifier.dart` | Parse topic |
| `mitra_app/lib/core/chat/widgets/chat_request_overlay.dart` | Badge + yellow accent |
| `mitra_app/lib/core/chat/mitra_chat_notifier.dart` | State + flip method + WS handler |
| `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart` | Yellow doodle + app-bar toggle |
| `mitra_app/lib/features/chat/widgets/extension_request_card.dart` | Badge + accent |
| `mitra_app/lib/features/chat/screens/chat_history_screen.dart` | Badge on row + yellow transcript |
| `mitra_app/pubspec.yaml` | Register new asset |
| `control_center/src/pages/settings/SettingsPage.jsx` | Sensitivity config section |
| `control_center/src/pages/sessions/SessionsPage.jsx` | Filter + column |
| `control_center/src/pages/dashboard/DashboardPage.jsx` | Sensitive stats card |
---
## 8. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Existing sessions have no `topic_sensitivity` value after migration | Column has `DEFAULT 'regular'` + NOT NULL — backfill automatic |
| Mitra flips topic but WS delivery fails to customer | Customer silently ignores anyway; backend state is source of truth, no user-facing consequence |
| Race: mitra flips to `sensitive`, then to `regular` while latch config toggles mid-session | `flipSessionSensitivity` reads latch config at flip-time; flip is atomic in a DB transaction |
| Customer cancels topic-selection sheet → home screen flashes | Confirm sheet result is `null` and return cleanly to home — no pairing request created |
| Yellow color clashes with existing pink UI elements | Restrict yellow to chat screen background + extension card; test contrast with message bubbles and badges |
| Mitra flips flag after session is `CLOSING` | Backend rejects with 409; mitra UI gracefully shows toast "Sesi sudah berakhir" |
| Old sessions in history have no audit log entries | No migration needed — history just shows current value with no log entries |
---
## 9. Testing Checklist
**Customer flow (client_app):**
- [ ] Tap "Mulai Curhat" → topic sheet appears, cannot dismiss by tap-outside
- [ ] System back cancels entire flow
- [ ] Topik umum → pricing sheet shows, pairing request sent with `topic_sensitivity: regular`
- [ ] Topik sensitif → pricing sheet shows, pairing request sent with `topic_sensitivity: sensitive`
- [ ] Chat screen stays pink regardless of flag
- [ ] Customer receives `session_topic_updated` WS message after mitra flip → no UI change, no error
**Mitra flow (mitra_app):**
- [ ] Sensitive incoming request → yellow accent + "Topik sensitif" badge on overlay
- [ ] Regular incoming request → no badge, no color change
- [ ] Sensitive active session → yellow doodle background + header label
- [ ] Flip toggle (confirmation enabled): dialog shows, Batal cancels, Tandai flips
- [ ] Flip toggle (confirmation disabled via CC): flips instantly with toast
- [ ] One-way latch enabled + session already sensitive: toggle disabled with tooltip
- [ ] One-way latch enabled + session regular: can flip to sensitive, then toggle locks
- [ ] Mitra requests extension on sensitive session → extension card shows badge + yellow
- [ ] Mitra requests extension after mid-session flip to sensitive → extension card reflects current flag
- [ ] History list: sensitive sessions show badge
- [ ] Transcript: sensitive sessions show yellow doodle
**Control center:**
- [ ] Settings page: toggle `sensitive_flip_confirmation_enabled` → mitra app behavior changes on next flip
- [ ] Settings page: toggle `sensitive_flag_one_way_latch` → mitra app behavior changes
- [ ] Sessions page: filter "Sensitif" only shows sensitive sessions
- [ ] Session detail: audit trail shows all flips with mitra name + timestamp
- [ ] Dashboard: sensitive session count + % matches manual query
- [ ] Mitra Activity: per-mitra sensitive total / accepted / rate columns populate correctly
- [ ] Mitra with 0 sensitive requests shows `—` (not 0%) for sensitive rate
**Backend:**
- [ ] `flipSessionSensitivity` logs every call in `session_sensitivity_log`
- [ ] Ownership check: mitra A cannot flip mitra B's session
- [ ] Status check: flip on `CLOSING`/`COMPLETED` session → 409
- [ ] Latch violation returns 409 with clear error code
- [ ] No-op flip (same value) does not create log entry

View File

@@ -0,0 +1,273 @@
# Phase 3.3 Testing & Outstanding Regression Checklist
This is a **reminder document** — consolidated testing work for Phase 3.3 plus every outstanding test item carried over from earlier Phase 3 iterations.
Tick boxes as you verify.
---
## Part 1 — Phase 3.3: Session Topic Sensitivity
### 1.1 Database / Migration
- [ ] Migration runs cleanly on an existing dev DB (no errors, all `IF NOT EXISTS` / `ON CONFLICT` paths hit)
- [ ] `chat_sessions.topic_sensitivity` column exists with default `'regular'` and NOT NULL
- [ ] Existing sessions (created before the migration) have `topic_sensitivity = 'regular'` after migration
- [ ] `session_sensitivity_log` table exists with correct FKs (sessions, mitras)
- [ ] `idx_chat_sessions_topic_sensitivity` index created
- [ ] `idx_session_sensitivity_log_session` index created
- [ ] `app_config` has `sensitive_flip_confirmation_enabled = true` by default
- [ ] `app_config` has `sensitive_flag_one_way_latch = false` by default
### 1.2 Customer Flow (client_app)
**Topic selection bottom sheet**
- [ ] Tap "Mulai Curhat" → topic selection bottom sheet appears
- [ ] Sheet **cannot** be dismissed by tapping outside
- [ ] Sheet **cannot** be dismissed by swiping down
- [ ] System back button cancels entire "Mulai Curhat" flow (does NOT open pricing)
- [ ] Copy matches PRD (title, body, sub-question, helper line)
- [ ] "Topik umum" button styling is primary
- [ ] "Topik sensitif" button styling is secondary but equal weight (not de-emphasized)
**Request submission**
- [ ] Tap "Topik umum" → pricing sheet opens with topic pre-selected as regular
- [ ] Tap "Topik sensitif" → pricing sheet opens with topic pre-selected as sensitive
- [ ] After pricing confirm → `POST /api/client/chat/request` body includes `topic_sensitivity: regular|sensitive`
- [ ] Backend rejects request with missing `topic_sensitivity` (400 BAD_REQUEST)
- [ ] Backend rejects request with invalid `topic_sensitivity` value (e.g., `"other"`)
- [ ] Created `chat_sessions` row has correct `topic_sensitivity` value
**Customer UI after request**
- [ ] Chat screen stays pink (no yellow), regardless of flag
- [ ] Customer history screen: no badge on any row regardless of flag
- [ ] Customer transcript screen: stays pink
- [ ] Customer receives `session_topic_updated` WS message after mitra flip → **no UI change**, no error, no crash
### 1.3 Mitra Flow — Incoming Request (mitra_app)
- [ ] Regular request: overlay has no badge, no yellow accent
- [ ] Sensitive request: overlay shows "Topik sensitif" badge + yellow accent
- [ ] Overlay payload from WS includes `topic_sensitivity`
- [ ] Overlay payload from FCM fallback includes `topic_sensitivity` (or app fetches on open)
- [ ] Overlay payload from `getPendingRequestsForMitra` (app-resume path) includes `topic_sensitivity`
- [ ] Mitra accepts sensitive request → lands in active chat screen with correct flag
### 1.4 Mitra Flow — Active Chat Screen
- [ ] Sensitive active session: yellow doodle background
- [ ] Regular active session: pink doodle (unchanged behavior)
- [ ] Header banner shows "Topik sensitif" label only when sensitive
- [ ] App-bar toggle icon visible (flag / flag_outlined depending on state)
**Flip toggle — confirmation enabled (default)**
- [ ] Tap toggle regular → sensitive → dialog appears: "Tandai sesi ini sebagai sensitif?"
- [ ] "Batal" cancels, no state change, no audit log entry, no WS broadcast
- [ ] "Tandai" flips, background turns yellow instantly, log entry created
- [ ] Tap toggle sensitive → regular → dialog: "Tandai sesi ini sebagai topik umum?"
**Flip toggle — confirmation disabled (via CC config)**
- [ ] Toggle `sensitive_flip_confirmation_enabled` to `false` in CC
- [ ] Flip happens immediately, toast "Sesi ditandai sensitif" / "Sesi ditandai topik umum"
- [ ] No dialog appears
**One-way latch — disabled (default)**
- [ ] Can flip regular → sensitive → regular → sensitive freely
**One-way latch — enabled (via CC config)**
- [ ] Toggle `sensitive_flag_one_way_latch` to `true` in CC
- [ ] Session that was regular: can flip to sensitive; toggle then disabled
- [ ] Session that was already sensitive at latch-enable time: toggle disabled with tooltip
- [ ] Attempt to flip sensitive → regular with latch on: API returns 409 `SENSITIVITY_LATCHED`
- [ ] Error dialog shown: "Sesi sudah ditandai sensitif dan tidak bisa diubah kembali."
**Audit trail**
- [ ] Every successful flip creates a `session_sensitivity_log` row with correct `from_value`, `to_value`, `changed_by_mitra_id`
- [ ] No-op flip (e.g., tap confirm but value didn't actually change) does NOT create a log row
- [ ] Log entries ordered correctly (ascending `created_at`)
### 1.5 Mitra Flow — Extension
- [ ] Customer requests extension on regular session → mitra extension card has no badge
- [ ] Customer requests extension on sensitive session → mitra extension card shows "Topik sensitif" badge + yellow accent
- [ ] Mitra flipped session mid-chat regular → sensitive, then customer requests extension → extension card reflects **current** sensitive flag
- [ ] Extension accepted → flag carries over unchanged to extended session
### 1.6 Mitra Flow — History & Transcript
- [ ] Mitra history list row shows "Topik sensitif" badge for sensitive sessions
- [ ] Mitra history list row has no badge for regular sessions
- [ ] Mitra transcript view: sensitive session → yellow doodle background
- [ ] Mitra transcript view: regular session → pink doodle
- [ ] Customer-side history and transcript: always pink, no badge
### 1.7 Mitra Flow — Edge Cases
- [ ] Mitra tries to flip flag on a session they don't own → 403 FORBIDDEN
- [ ] Mitra tries to flip flag on a `CLOSING` session → 409 SESSION_NOT_ACTIVE
- [ ] Mitra tries to flip flag on a `COMPLETED` session → 409 SESSION_NOT_ACTIVE
- [ ] Mitra tries to flip flag on an `EXPIRED` session → 409 SESSION_NOT_ACTIVE
- [ ] Invalid `topic_sensitivity` value sent to PATCH endpoint → 400 BAD_REQUEST
- [ ] Customer tries to call PATCH endpoint → 403 FORBIDDEN (only mitra allowed)
### 1.8 Control Center — Settings
- [ ] Settings page has new "Sensitivitas Topik" section
- [ ] `sensitive_flip_confirmation_enabled` checkbox reflects current backend value
- [ ] `sensitive_flag_one_way_latch` checkbox reflects current backend value
- [ ] PATCH `/internal/config/sensitivity` persists changes
- [ ] Changes take effect immediately on next mitra flip (no app restart needed on mitra side, if mitra fetches config dynamically)
### 1.9 Control Center — Sessions Page
- [ ] Sessions list has new filter dropdown (All / Umum / Sensitif)
- [ ] Filter "Sensitif" returns only sessions with `topic_sensitivity = 'sensitive'`
- [ ] Filter "Umum" returns only `regular`
- [ ] Filter "All" returns everything (backward-compatible)
- [ ] Filter works combined with existing status filter
- [ ] Session list has new "Topik" column showing badge (green "Umum" / yellow "Sensitif")
### 1.10 Control Center — Session Detail
- [ ] Session detail page shows current `topic_sensitivity`
- [ ] Session detail shows sensitivity audit trail timeline: "Mitra {name} menandai topik sebagai {from→to} pada {timestamp}"
- [ ] Timeline ordered ascending
- [ ] Sessions with no flips show empty timeline (no error)
### 1.11 Control Center — Dashboard
- [ ] Dashboard shows "Sesi Sensitif" card with total count + 30-day % breakdown
- [ ] Percentage math correct (sensitive / total × 100, rounded to 1 decimal)
- [ ] Edge case: 0 sessions in last 30 days → shows `0%` not `NaN`
### 1.12 Control Center — Mitra Activity
- [ ] Summary table has new columns: Sensitive Total, Sensitive Accepted, Sensitive Rate (%)
- [ ] Mitra with 0 sensitive requests shows `—` (not `0%`)
- [ ] Sensitive rate computed correctly (sensitive_accepted / sensitive_total × 100)
- [ ] Detail log table: optional new "Topik" column with badge
- [ ] Date range filter still works with new columns
### 1.13 Control Center — Integration Regression
- [ ] Existing settings (anonymity, free-trial, extension-timeout, early-end, mitra-ping, price-tiers) still work
- [ ] Existing sessions filter (by status) still works
- [ ] Existing dashboard cards still render
---
## Part 2 — Outstanding Items From Phase 3.2
Carried over from `project_phase3_testing_status.md` (2026-04-15):
### 2.1 Chat Request Overlay
- [ ] **Multiple concurrent chat requests** — verify queue behavior (one shown at a time, next appears when current resolved)
- [ ] Stale request: "cancelled by customer" message shown + requires acknowledge (no auto-dismiss)
- [ ] Stale request: "accepted by other bestie" message shown + requires acknowledge
- [ ] Stale request: "expired" message shown + requires acknowledge
- [ ] Swipe-to-dismiss (ignore) does NOT send reject to backend
- [ ] Ignored request eventually logs as `ignored` in `chat_request_notifications` after 60s timeout
- [ ] Request `missed` (another mitra accepted first) logs correctly
- [ ] `active_session_count` captured correctly at notification creation
### 2.2 End-to-End Flows
- [ ] Full chat flow: pair → chat → extension → closure (customer + mitra)
- [ ] Goodbye flow: session expires → closing → both submit goodbye → completed
- [ ] Extension accepted mid-flow → session resumes, timer extends, no grace timer lingering
- [ ] Extension rejected → session moves to closing, both see closure UI
- [ ] Extension timeout (no mitra response) → closing
### 2.3 iOS Coverage (still partially untested)
- [ ] OTP login on iOS (customer)
- [ ] OTP login on iOS (mitra)
- [ ] Push notifications on iOS (customer + mitra)
- [ ] FCM token registration on iOS
- [ ] Chat screen rendering on iOS
- [ ] Back button behavior on iOS (deep-link pop fallback)
- [ ] Overlay on iOS (from `project_phase3_testing_status`: iOS setup started but incomplete)
- [ ] Splash screen on iOS
- [ ] Onboarding carousel on iOS
- [ ] Keyboard handling on iOS (chat input, goodbye form)
---
## Part 3 — Outstanding Items From Phase 3 / 3.1
### 3.1 Session Lifecycle
- [ ] Server restart mid-session: session timer is restored from DB (`restoreActiveTimers`)
- [ ] Stale active sessions auto-complete on restart
- [ ] Closing sessions with stale grace timers auto-complete on restart
- [ ] Session expired from customer side (5-min countdown display)
- [ ] Abandoned session during closure grace period → auto-completes
- [ ] **Known limitation**: multi-instance backend sessions not supported until Valkey keyspace notifications implemented (out of scope, just confirm single-instance behavior)
### 3.2 Chat Mechanics
- [ ] Message status transitions (sent → delivered → read) work correctly
- [ ] Typing indicator shows/hides correctly on both sides
- [ ] Messages received while backgrounded are marked `delivered` on foreground resume
- [ ] Messages viewed are marked `read` and the read receipt propagates back to sender
- [ ] Unread badge on home screen updates correctly (client_app + mitra_app)
### 3.3 Navigation / UI
- [ ] All navigation uses `GoRouter.context.push/go` (no leftover `Navigator.pushNamed`)
- [ ] Deep-linked screens work with `canPop` fallback + `PopScope`
- [ ] `notification_service` uses `go` (not `push`) for terminal states
- [ ] Splash screen hides auth loading flash on both apps
- [ ] Goodbye views use `SingleChildScrollView` (no keyboard overflow)
### 3.4 Control Center Settings
- [ ] Free trial config: toggle + duration edit
- [ ] Extension timeout: edit seconds
- [ ] Early end: toggle mitra / customer independently
- [ ] Mitra ping: toggle require + interval
- [ ] Price tiers: add / edit / remove tiers and verify client_app pricing sheet reflects changes
---
## Part 4 — Cross-Cutting / Pre-Release
### 4.1 Regression Checks (do after Phase 3.3 merge)
- [ ] Existing customer auth flow still works (welcome → OTP → register → home)
- [ ] Existing mitra auth flow still works
- [ ] Existing control center login still works (admin@halobestie.com)
- [ ] Pairing flow (mulai curhat → matched) still works end-to-end
- [ ] All existing WS messages still processed (no regressions from new `session_topic_updated` handler)
### 4.2 Platform Coverage
- [ ] Android: customer app on emulator (Medium_Phone_API_36.1)
- [ ] Android: mitra app on physical device (SM-A530F, 52002a5db8e0c46b)
- [ ] iOS: customer app (Mac + simulator / physical)
- [ ] iOS: mitra app (Mac + simulator / physical)
- [ ] Control center: Chrome latest
- [ ] Control center: Firefox / Safari (if required)
### 4.3 Load / Concurrency (sanity)
- [ ] 2 concurrent customers requesting chat at the same time — both find a mitra (or one waits)
- [ ] 1 customer, 5 mitras online — blast notification reaches all 5
- [ ] Mitra accepts after another mitra already accepted → receives `missed` with `accepted_by_other`
- [ ] Backend restart with active sessions → timers restored, no data loss
### 4.4 Config Flag Interactions
- [ ] `sensitive_flag_one_way_latch = true` + existing sensitive session → toggle disabled
- [ ] `sensitive_flip_confirmation_enabled = false` + rapid flips → no race, all logged in order
- [ ] Both config flags toggled together → no conflict
### 4.5 Known Blockers / Deferred
Not tests — tracked here so they don't get forgotten:
- [ ] **Valkey keyspace notifications** — required for multi-instance session timers (noted in memory as future work)
- [ ] **Mitra QC auto-flag** — auto-flagging high-rejection mitras on CC (future phase)
- [ ] **Merge-on-link** for social login (currently reject-on-existing)
- [ ] **Phase 3.4 auth migration** — separate phase, not blocking 3.3 testing

216
requirement/phase3.3.md Normal file
View File

@@ -0,0 +1,216 @@
# PRD: Session Topic Sensitivity
# Overview
**Goal:** Let customers flag a chat session as containing sensitive topics (sexuality, pornography, substance use) so mitras can prepare themselves, and give mitras the ability to re-flag the session mid-chat when the topic drifts.
**Success looks like:** Every new chat request carries an explicit topic flag (regular or sensitive), the mitra sees the flag on the incoming request and during the chat, mitras can decline on the spot based on the flag, and mitras can change the flag mid-session with an audit trail.
**Affects:** `client_app`, `mitra_app`, `backend`, `control_center`
## Background
- Current chat flow has no way for customers to warn mitras about sensitive topics (seksualitas, pornografi, penggunaan zat) before the session starts.
- Mitras cannot prepare themselves or opt out based on topic sensitivity, which can lead to mid-session discomfort or early terminations.
- Topics may also drift during a session; there is currently no way for the mitra to re-classify the session.
---
# Functional Requirement
## 1. Customer: Topic Selection Before Pricing
### Trigger
- Customer taps **"Mulai Curhat"** on the home screen.
- Before the pricing screen is shown, a bottom sheet appears asking the customer to classify their topic.
### Appearance
- Bottom sheet, rounded top corners, consistent with existing app theme (pink doodle background).
- Cannot be dismissed by tapping outside or swiping down — selection is **required** before the pricing screen is shown.
- Back button (or system back) cancels the entire "Mulai Curhat" flow.
### Copy (Indonesian, non-judgmental)
- **Title:** Sebelum kita mulai
- **Body:** Supaya kami bisa menyiapkan bestie yang tepat untukmu, boleh kami tahu sedikit tentang ceritamu? Tidak ada penilaian di sini — kamu aman bercerita apa pun.
- **Sub-question:** Apakah ceritamu menyentuh topik seperti seksualitas, pornografi, atau penggunaan zat?
- **Button A (primary):** Topik umum
- **Button B (secondary, equal weight):** Topik sensitif
- **Helper line below buttons:** Pilihan ini hanya membantu bestie menyiapkan diri. Kamu tetap bisa bercerita apa adanya.
### Behavior
- Tapping either button stores the choice locally and proceeds to the existing pricing screen.
- Customer **cannot change** the flag after this selection — it is locked for the duration of the session.
- Flag is sent to the backend when the chat request is created (part of the pairing request payload).
---
## 2. Mitra: Sensitive Flag on Incoming Chat Request
### Appearance on Overlay
- Incoming chat request overlay (from Phase 3.2) shows a **label** and **color accent** when the request is flagged sensitive.
- Label: **"Topik sensitif"** (small badge, placed near the session metadata).
- Color accent: warning-sign yellow (see Section 5 for exact color treatment).
- Regular requests remain as today — no badge, no color change.
### Behavior
- Mitra may accept or decline as today. No new dedicated "decline because sensitive" flow — the existing reject/ignore paths are sufficient.
- No per-mitra opt-in setting (e.g., "I accept sensitive topics") in this phase.
### Push Notification
- Push notification content (title/body) stays generic for now — the sensitivity label is surfaced only in the overlay.
---
## 3. Mitra: Chat Screen Background Color
### Sensitive Sessions
- Chat screen background switches from pink doodle to **warning-yellow doodle** (same pattern, different base color).
- A small persistent header or banner shows **"Topik sensitif"** label so the context is always visible.
### Regular Sessions
- No change — pink doodle as today.
### Customer Side
- Customer's chat screen **always** shows pink doodle, regardless of flag.
- Customer is never given a color cue about topic sensitivity, even if the mitra later flips the flag.
---
## 4. Mitra: Flip Flag Mid-Session
### Control
- Toggle icon in the mitra's chat screen app bar.
- Tapping it flips the session flag (regular ↔ sensitive).
### Confirmation Dialog
- By default, flipping shows a confirmation dialog:
- **regular → sensitive:** "Tandai sesi ini sebagai sensitif?" — [Batal] [Tandai]
- **sensitive → regular:** "Tandai sesi ini sebagai topik umum?" — [Batal] [Tandai]
- The confirmation can be disabled via `app_config` key `sensitive_flip_confirmation_enabled` (default `true`). When disabled, the flip happens instantly and a toast appears ("Sesi ditandai sensitif" / "Sesi ditandai topik umum").
### One-Way Latch (Configurable)
- By default, mitra can flip back and forth (regular ↔ sensitive).
- A `app_config` key `sensitive_flag_one_way_latch` (default `false`) toggles to one-way mode: once sensitive, cannot revert to regular.
- When latch is active and the session is already sensitive, the toggle icon is disabled (with a tooltip explaining why).
### Persistence
- The flip updates `sessions.topic_sensitivity` immediately (backend-authoritative).
- Every flip is recorded in `session_sensitivity_log` (see Section 7).
### WebSocket Event
- Mitra's flip triggers a WS event to the customer (for future-proofing / analytics), but the customer's client **silently ignores it** — no UI change.
- Multi-device mitra sync is not required in this phase (mitra app doesn't yet support concurrent multi-device sessions).
---
## 5. Visual Treatment
### Color Palette
- **Pink** (existing): current doodle theme, used for regular sessions on both sides and for customer side always.
- **Yellow**: warning-sign yellow, using the same doodle pattern as pink. Exact hex TBD during implementation — should read clearly as "attention / caution" without feeling alarming.
### Badge / Label
- **"Topik sensitif"** pill-style badge, pink-text-on-yellow or dark-text-on-yellow for contrast.
- Used on:
- Incoming chat request overlay (mitra)
- Chat screen header (mitra)
- Extension request card (mitra)
- Mitra chat history list row
- Mitra transcript / history detail
### Customer Side
- No yellow, no badge, no label — ever.
---
## 6. Extension Flow
### Mitra's Extension Request Card
- When a customer requests extension, the mitra's extension UI shows the **current** session flag (i.e., whatever the flag is at the time the extension request arrives, including any mid-session mitra flip).
- Treatment: same as incoming chat request — yellow color accent + "Topik sensitif" badge when applicable.
### On Extension Acceptance
- The flag **carries over** to the extended session unchanged.
- Customer is not re-asked about topic sensitivity on extension.
---
## 7. Audit Trail
### Table: `session_sensitivity_log`
- `id` — PK
- `session_id` — FK → sessions
- `changed_by_mitra_id` — FK → mitras
- `from_value``regular` | `sensitive`
- `to_value``regular` | `sensitive`
- `created_at` — timestamp
### When Logged
- Every mitra flip (both directions).
- Customer's initial selection is NOT logged here — it is the initial value of `sessions.topic_sensitivity`.
---
## 8. Mitra Chat History
### List Rows
- Each past session row shows a small **"Topik sensitif"** badge if the session was flagged sensitive at any point.
- Regular sessions have no badge.
### Transcript Detail
- Sensitive sessions show the yellow doodle background in the transcript view.
- Regular sessions show pink.
### Customer Side
- Customer's history and transcript views are unchanged — no badge, no color change, ever.
---
## 9. Control Center
### Dashboard: Sensitive vs Regular Stats
- New panel on the existing dashboard or session-management page: count and percentage of sensitive sessions over a date range.
- Optionally: per-mitra breakdown (future nice-to-have — not blocking for this phase).
### Session Management Filter
- Add a filter / sort on the session management page by `topic_sensitivity` (all / regular / sensitive).
### Config
- Expose two new `app_config` keys on the existing config page:
- `sensitive_flip_confirmation_enabled` (bool, default `true`) — whether mitra sees a confirmation dialog when flipping the flag.
- `sensitive_flag_one_way_latch` (bool, default `false`) — whether mitra can only flip regular → sensitive (no flip back).
---
## 10. Out of Scope for Phase 3.3
- **Pricing differentiation** based on topic sensitivity. For now, the flag does not affect pricing. Schema should leave room for this in the future.
- **Per-mitra opt-in** setting ("I accept sensitive topics"). Mitras decline on the spot via existing reject/ignore paths.
- **Auto-moderation or keyword detection** — flag is self-reported by customer / mitra only.
- **FCM push notification content** reflecting sensitivity — generic push only; label is in-app overlay only.
- **Multi-device mitra sync** of flag flips.
- **Customer-side visual cues** about topic sensitivity.
---
# Data Model Summary
### New columns
- `sessions.topic_sensitivity` — enum `regular | sensitive`, NOT NULL, default `regular`.
- `chat_requests.topic_sensitivity` — enum `regular | sensitive`, NOT NULL, default `regular` (mirrored for pre-acceptance visibility).
### New table
- `session_sensitivity_log` (see Section 7).
### New `app_config` keys
- `sensitive_flip_confirmation_enabled` (bool, default `true`)
- `sensitive_flag_one_way_latch` (bool, default `false`)
---
# Tech Stack
- Flutter for client_app / mitra_app (existing Riverpod state management)
- Fastify backend (existing WebSocket plugin + pairing/extension services)
- PostgreSQL migration for new columns + table
- Control center: React + Vite (existing pages extended)

View File

@@ -0,0 +1,742 @@
# Phase 3.4 Implementation Plan: Self-Managed Authentication
## Summary of Clarified Requirements
| Topic | Decision |
|---|---|
| SMS/WhatsApp OTP provider | Fazpass (server-side flow, WhatsApp-first with SMS fallback) |
| Google Sign-In | client_app only; native `google_sign_in` → backend verifies Google ID token |
| Apple Sign-In | client_app only; native `sign_in_with_apple` → backend verifies Apple ID token |
| Mitra auth | Phone OTP only (unchanged scope) |
| Control center auth | Email/password; bcrypt; admin-only provisioning; no password reset |
| Access token | JWT HS256, 1h TTL, includes `session_id` claim |
| Refresh token | Opaque 32-byte random, 30d TTL, rotated on use, bcrypt-hashed in DB |
| Multi-device | Each login = new `auth_sessions` row |
| Revocation (now) | Delete `auth_sessions` row → 1h window until access token dies |
| Revocation (later) | Valkey `revoked_sessions` set, pre-wired via `session_id` claim |
| Anonymous customer | Server-issued anonymous JWT + refresh token (no device ID) |
| Link account (existing customer) | Reject-on-existing for now; merge deferred |
| Data migration | Dev DB can be wiped; cross-app prod migration deferred |
| FCM push | Kept — `firebase-admin` stays but only `.messaging()` used |
| CC access token storage | In-memory (JS variable in AuthContext) |
| CC refresh token storage | httpOnly secure cookie |
| Password hashing | bcrypt cost factor 12 |
| Password complexity | min 8 chars, ≥1 digit, ≥1 uppercase, ≥1 lowercase |
| Rate limits | Configurable via `app_config` (defaults: 3/phone/hour, 10/IP/hour, 60s resend, 5 verify attempts) |
| CC login lockout | 5 failed attempts → 15-min lockout |
| Session fingerprinting | `user_agent` + `ip` in `auth_sessions.device_info` JSONB |
| JWT secret rotation | Single secret for now; rotation procedure documented but not implemented |
| Fazpass API shape | ⚠ **TBD** — pending real API docs; placeholder fields in migration and service |
---
## Prerequisites (Must Be Done Before Coding)
1. **Apple Developer account** active ($99/year), Services ID created (e.g. `com.halobestie.client.signin`), `.p8` private key + Key ID + Team ID on hand
2. **Fazpass** account provisioned with API key; real API docs obtained (auth method, send OTP endpoint, verify endpoint, webhook spec)
3. **Google OAuth Client IDs** per platform recorded (Android, iOS) — likely already exist from Firebase setup, survive Firebase removal
4. **Agreement** that dev DB can be wiped
5. **Env vars provisioned** in dev / staging / prod:
- `AUTH_JWT_SECRET` (min 32 random bytes)
- `FAZPASS_API_KEY`, `FAZPASS_BASE_URL`, `FAZPASS_WEBHOOK_SECRET` (if callbacks used)
- `GOOGLE_OAUTH_CLIENT_IDS` (comma-separated)
- `APPLE_SERVICES_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY`
- `ADMIN_EMAIL`, `ADMIN_PASSWORD` (CC seed)
---
## Work Stream 1: Backend — Schema, Services, Routes
### 1.1 DB Migration
**File:** [backend/src/db/migrate.js](backend/src/db/migrate.js)
```sql
-- Drop firebase_uid columns
ALTER TABLE customers DROP COLUMN IF EXISTS firebase_uid;
ALTER TABLE mitras DROP COLUMN IF EXISTS firebase_uid;
ALTER TABLE control_center_users DROP COLUMN IF EXISTS firebase_uid;
-- Add social identity columns to customers
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS email VARCHAR(255),
ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255) UNIQUE,
ADD COLUMN IF NOT EXISTS apple_sub VARCHAR(255) UNIQUE;
-- CC password columns
ALTER TABLE control_center_users
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(60) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS failed_login_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS lockout_until TIMESTAMPTZ;
-- (after backfilling real hashes, drop the DEFAULT '')
-- New auth_sessions table
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_type VARCHAR(16) NOT NULL,
user_id UUID NOT NULL,
refresh_token_hash VARCHAR(60) NOT NULL,
device_info JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions (user_type, user_id);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL;
-- New otp_requests table (shape is provisional — confirm with Fazpass docs)
CREATE TABLE IF NOT EXISTS otp_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) NOT NULL,
fazpass_reference VARCHAR(255) NOT NULL,
channel VARCHAR(16),
attempts INT NOT NULL DEFAULT 0,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at);
-- app_config defaults
INSERT INTO app_config (key, value) VALUES
('otp_max_per_phone_per_hour', '{"value": 3}'),
('otp_max_per_ip_per_hour', '{"value": 10}'),
('otp_resend_cooldown_seconds', '{"value": 60}'),
('otp_verify_max_attempts', '{"value": 5}'),
('cc_login_max_attempts', '{"value": 5}'),
('cc_login_lockout_minutes', '{"value": 15}')
ON CONFLICT (key) DO NOTHING;
```
### 1.2 Constants
**File:** [backend/src/constants.js](backend/src/constants.js)
```js
export const AuthProvider = Object.freeze({
ANONYMOUS: 'anonymous',
PHONE: 'phone',
GOOGLE: 'google',
APPLE: 'apple',
PASSWORD: 'password',
})
export const OtpChannel = Object.freeze({
WHATSAPP: 'whatsapp',
SMS: 'sms',
})
```
### 1.3 Config Service Additions
**File:** [backend/src/services/config.service.js](backend/src/services/config.service.js)
Add getters/setters for:
- `getOtpRateLimits()``{ max_per_phone_per_hour, max_per_ip_per_hour }`
- `getOtpResendCooldownSeconds()`
- `getOtpVerifyMaxAttempts()`
- `getCcLoginLockoutConfig()``{ max_attempts, lockout_minutes }`
- Matching setters for CC settings page
### 1.4 New Service: Token Service
**New file:** `backend/src/services/token.service.js`
Exports:
- `issueTokens({ userType, userId, deviceInfo })` — creates `auth_sessions` row + returns `{ access_token, refresh_token, expires_at }`
- `refreshTokens({ refresh_token, deviceInfo })` — validates + rotates + returns new pair
- `revokeSession({ sessionId })` — marks `revoked_at`; deletes the row
- `verifyAccessToken(token)` — returns decoded claims or throws
- `hashRefreshToken(raw)` — bcrypt
- `generateRefreshToken()` — 32-byte crypto random base64url
Internals:
- Access token claims: `{ sub, user_type, session_id, iat, exp }`
- HS256 with `AUTH_JWT_SECRET`
- Access TTL: 1h (`ACCESS_TOKEN_TTL_SECONDS` env, default 3600)
- Refresh TTL: 30d (`REFRESH_TOKEN_TTL_DAYS` env, default 30)
### 1.5 New Service: OTP Service
**New file:** `backend/src/services/otp.service.js`
Exports:
- `requestOtp({ phone, ipAddress })` — enforces rate limits, calls Fazpass send, inserts `otp_requests` row
- `verifyOtp({ otpRequestId, code })` — calls Fazpass verify, increments attempts, marks used
- Helpers: `checkOtpRateLimit(phone, ip)` using `app_config` + `otp_requests` history
**Fazpass-specific parts TBD** until real API docs are in hand. Placeholder functions:
```js
async function fazpassSendOtp({ phone, channel }) {
// TBD: POST to Fazpass send endpoint
// Returns: { reference, channel_used, expires_at }
}
async function fazpassVerifyOtp({ reference, code }) {
// TBD: POST to Fazpass verify endpoint
// Returns: { valid: bool }
}
```
### 1.6 New Service: Social Identity Service
**New file:** `backend/src/services/social-identity.service.js`
Exports:
- `verifyGoogleIdToken(idToken)` — uses `google-auth-library` `OAuth2Client.verifyIdToken`, validates audience against env `GOOGLE_OAUTH_CLIENT_IDS`, returns `{ sub, email, name, email_verified }`
- `verifyAppleIdToken(idToken)` — fetches Apple JWKS, verifies JWT signature + audience, returns `{ sub, email? }`
### 1.7 New Service: Auth Service
**New file:** `backend/src/services/auth.service.js`
Orchestrates sign-in flows. Exports:
- `signInAnonymous({ deviceInfo })` — creates customer row with generated display name, issues tokens
- `signInWithPhone({ phone, deviceInfo, upgradeFromCustomerId? })` — lookup/create customer by phone (or upgrade anonymous), issue tokens
- `signInMitraWithPhone({ phone, deviceInfo })` — lookup/create mitra by phone, issue tokens
- `signInWithGoogle({ idToken, deviceInfo, upgradeFromCustomerId? })` — verify, lookup/create/upgrade, issue tokens (409 if google_sub already linked to another customer)
- `signInWithApple({ idToken, deviceInfo, upgradeFromCustomerId? })` — same as Google
- `signInCcUser({ email, password, deviceInfo })` — verify password, lockout check, issue tokens
- `logout({ sessionId })` — delegate to token service
### 1.8 Password Service
**New file:** `backend/src/services/password.service.js`
Exports:
- `hashPassword(plain)` — bcrypt 12
- `verifyPassword(plain, hash)` — bcrypt compare
- `validateComplexity(plain)` — min 8 chars, ≥1 digit, ≥1 uppercase, ≥1 lowercase; throws with specific error code
### 1.9 Middleware Rewrite: `authenticate`
**File:** [backend/src/plugins/auth.js](backend/src/plugins/auth.js)
Replace Firebase verification with:
- Extract `Authorization: Bearer <token>` header
- Call `verifyAccessToken(token)` from token service
- On success: attach `request.auth = { userType, userId, sessionId }`
- On failure: return 401 UNAUTHORIZED
- **Future hook for Valkey revocation**: after JWT verification, check `SISMEMBER revoked_sessions <session_id>` — skipped in this phase but the code location is noted for later
Remove entirely:
- `verifyFirebaseToken` usage
- `request.firebaseUser.uid` references
### 1.10 Update All Routes
Every route that currently does `getCustomerByFirebaseUid(request.firebaseUser.uid)` (or mitra/cc variant) must be updated to read `request.auth.userId` + `request.auth.userType` directly. No more DB lookup needed — the token already contains the resolved ID.
Files:
- [backend/src/routes/public/client.chat.routes.js](backend/src/routes/public/client.chat.routes.js)
- [backend/src/routes/public/mitra.chat.routes.js](backend/src/routes/public/mitra.chat.routes.js)
- [backend/src/routes/public/shared.chat.routes.js](backend/src/routes/public/shared.chat.routes.js)
- [backend/src/routes/public/mitra.status.routes.js](backend/src/routes/public/mitra.status.routes.js)
- [backend/src/routes/internal/config.routes.js](backend/src/routes/internal/config.routes.js)
- [backend/src/routes/internal/roles.routes.js](backend/src/routes/internal/roles.routes.js)
- [backend/src/routes/internal/mitra.routes.js](backend/src/routes/internal/mitra.routes.js)
- [backend/src/routes/internal/mitra-activity.routes.js](backend/src/routes/internal/mitra-activity.routes.js)
- [backend/src/routes/internal/cc-user.routes.js](backend/src/routes/internal/cc-user.routes.js)
- [backend/src/routes/internal/session.routes.js](backend/src/routes/internal/session.routes.js)
Convert `resolveCustomer` / `resolveMitra` / `attachCcUser` helpers to use `request.auth` instead of Firebase.
### 1.11 Replace Auth Route Files
**Rewrite:** [backend/src/routes/public/client.auth.routes.js](backend/src/routes/public/client.auth.routes.js)
New endpoints (all public, no auth unless noted):
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/shared/auth/anonymous` | Create anonymous customer + tokens |
| POST | `/api/client/auth/otp/request` | Start phone OTP (customer) |
| POST | `/api/client/auth/otp/verify` | Verify phone OTP (customer) |
| POST | `/api/client/auth/google` | Google sign-in |
| POST | `/api/client/auth/apple` | Apple sign-in |
| POST | `/api/shared/auth/refresh` | Refresh token rotation |
| POST | `/api/shared/auth/logout` | Logout (authenticated) |
| GET | `/api/client/auth/me` | Current user profile (authenticated) |
**Rewrite:** [backend/src/routes/public/mitra.auth.routes.js](backend/src/routes/public/mitra.auth.routes.js)
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/mitra/auth/otp/request` | Start phone OTP (mitra) |
| POST | `/api/mitra/auth/otp/verify` | Verify phone OTP (mitra) |
| GET | `/api/mitra/auth/me` | Current mitra profile (authenticated) |
**Rewrite:** [backend/src/routes/internal/auth.routes.js](backend/src/routes/internal/auth.routes.js)
| Method | Path | Purpose |
|---|---|---|
| POST | `/internal/auth/login` | CC email/password login (sets httpOnly refresh cookie + returns access token in body) |
| POST | `/internal/auth/refresh` | Refresh using httpOnly cookie |
| POST | `/internal/auth/logout` | Clear cookie + delete session |
| GET | `/internal/auth/me` | Current CC user profile |
**New:** `backend/src/routes/internal/cc-user.routes.js` — password endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | `/internal/cc-user` | Create CC user (super admin only; accepts initial password) |
| PATCH | `/internal/cc-user/me/password` | Self-service change (current_password + new_password) |
| PATCH | `/internal/cc-user/:id/password` | Admin-forced reset (super admin only) |
Existing CC user listing / create (currently calls Firebase) is reworked to accept plain-password fields.
### 1.12 WebSocket Plugin Update
**File:** [backend/src/plugins/websocket.js](backend/src/plugins/websocket.js)
Replace `verifyFirebaseToken(msg.token)` with `verifyAccessToken(msg.token)`. Remove customer/mitra lookup (now encoded in the JWT claims).
### 1.13 FCM Plugin Isolation
**File:** [backend/src/plugins/firebase.js](backend/src/plugins/firebase.js) → rename to `backend/src/plugins/fcm.js`
Keep `firebase-admin` import but only expose `admin.messaging()`. Delete `verifyFirebaseToken` and Firebase Auth exports. All callers of `verifyFirebaseToken` are already rewritten in 1.9.
### 1.14 Seed Script Rewrite
**File:** [backend/src/db/seed.js](backend/src/db/seed.js)
Replace `admin.auth().createUser()` / `admin.auth().getUserByEmail()` with direct DB insert:
```js
const email = process.env.ADMIN_EMAIL
const password = process.env.ADMIN_PASSWORD
const passwordHash = await hashPassword(password)
await sql`
INSERT INTO control_center_users (email, password_hash, display_name, role_id)
VALUES (${email}, ${passwordHash}, 'Super Admin', ${superAdminRoleId})
ON CONFLICT (email) DO NOTHING
`
```
### 1.15 Dependency Changes
**File:** `backend/package.json`
Add:
- `jsonwebtoken`
- `bcrypt`
- `google-auth-library`
- `apple-signin-auth` OR implement manual Apple JWKS verification with `jsonwebtoken` + `jwks-rsa`
- HTTP client for Fazpass (can reuse `undici` if Node 18+)
Keep:
- `firebase-admin` (for FCM only)
- `@fastify/cors`, `@fastify/websocket`, `postgres`, `dotenv`, etc.
---
## Work Stream 2: client_app (Flutter)
### 2.1 Dependencies
**File:** `client_app/pubspec.yaml`
Remove:
- `firebase_auth`
Keep:
- `firebase_core`, `firebase_messaging` (for FCM)
- `google_sign_in`, `sign_in_with_apple`
Add:
- `flutter_secure_storage` (if not present)
- `jwt_decoder` (optional, for reading token expiry on client)
### 2.2 New: Secure Storage Service
**New file:** `client_app/lib/core/auth/token_storage.dart`
Wrapper around `flutter_secure_storage`:
- `saveRefreshToken(String)`
- `getRefreshToken()``String?`
- `clearRefreshToken()`
- `saveAccessToken(String)` / `getAccessToken()` / `clearAccessToken()` (access token can live in memory but keeping encrypted storage is safer against cold-start race)
### 2.3 New: Auth Notifier Rewrite
**File:** [client_app/lib/core/auth/auth_notifier.dart](client_app/lib/core/auth/auth_notifier.dart)
Replace Firebase-based flows. New methods (Riverpod `Notifier`):
- `bootstrap()` — on app start, check for refresh token → refresh → set authed state; else call `signInAnonymous()`
- `signInAnonymous()` — POST `/api/shared/auth/anonymous`
- `requestOtp(phone)` — POST `/api/client/auth/otp/request`
- `verifyOtp(otpRequestId, code)` — POST `/api/client/auth/otp/verify`, passes current anonymous customer session_id for upgrade
- `signInWithGoogle()` — native `google_sign_in` → POST `/api/client/auth/google`
- `signInWithApple()` — native `sign_in_with_apple` → POST `/api/client/auth/apple`
- `logout()` — POST `/api/shared/auth/logout`, clear secure storage
State shape: `{ accessToken, refreshToken, profile, authStatus }`
### 2.4 API Client Interceptor Rewrite
**File:** [client_app/lib/core/api/api_client.dart](client_app/lib/core/api/api_client.dart)
Replace `user.getIdToken()` with in-memory access token from `authNotifier`:
- Request interceptor: attach `Authorization: Bearer <access_token>`
- Response interceptor: on 401, try refresh once; if refresh fails, trigger logout
- No more Firebase dependency
### 2.5 WebSocket Auth Update
**File:** [client_app/lib/core/chat/chat_notifier.dart](client_app/lib/core/chat/chat_notifier.dart)
Replace `FirebaseAuth.instance.currentUser!.getIdToken()` with current access token from `authNotifier`. If expired, refresh first.
### 2.6 Remove Firebase Auth Initialization
**File:** [client_app/lib/main.dart](client_app/lib/main.dart)
Keep `Firebase.initializeApp()` for FCM. Remove all `FirebaseAuth.instance` references (auth state listener, etc.). Replace with `authNotifier.bootstrap()` call.
### 2.7 Remove Firebase Options (Partial)
**File:** [client_app/lib/firebase_options.dart](client_app/lib/firebase_options.dart)
Keep — FCM still needs Firebase project config. Just don't call `FirebaseAuth` anywhere.
### 2.8 Auth Screens
**Files:** `client_app/lib/features/auth/screens/*`
Update each screen to call new `AuthNotifier` methods. The screen structure (welcome, OTP, register, force-register, display name) stays the same — only the underlying method calls change.
### 2.9 FCM Token Registration
**File:** [client_app/lib/core/notifications/notification_service.dart](client_app/lib/core/notifications/notification_service.dart)
`FirebaseMessaging.instance.getToken()` still works. Registration endpoint (`POST /api/shared/device-token`) is unchanged — only auth header changes (new JWT instead of Firebase token).
---
## Work Stream 3: mitra_app (Flutter)
Identical pattern to client_app but phone-only. No Google/Apple code added.
### 3.1 Dependencies
**File:** `mitra_app/pubspec.yaml`
Remove `firebase_auth`. Keep `firebase_core`, `firebase_messaging`. Add `flutter_secure_storage`.
### 3.2 Token Storage + Auth Notifier + API Client + WebSocket
Same shape as client_app changes above.
**Files:**
- New: `mitra_app/lib/core/auth/token_storage.dart`
- Rewrite: [mitra_app/lib/core/auth/auth_notifier.dart](mitra_app/lib/core/auth/auth_notifier.dart) — methods: `bootstrap`, `requestOtp`, `verifyOtp`, `logout`
- Update: [mitra_app/lib/core/api/api_client.dart](mitra_app/lib/core/api/api_client.dart)
- Update: [mitra_app/lib/core/chat/mitra_chat_notifier.dart](mitra_app/lib/core/chat/mitra_chat_notifier.dart) and [mitra_app/lib/core/chat/chat_request_notifier.dart](mitra_app/lib/core/chat/chat_request_notifier.dart) — replace Firebase token retrieval in WS auth
### 3.3 Auth Screens
**Files:** `mitra_app/lib/features/auth/screens/*`
Update OTP screens to call new notifier methods. Screen structure stays.
---
## Work Stream 4: control_center (React)
### 4.1 Dependencies
**File:** `control_center/package.json`
Remove `firebase`.
### 4.2 Remove Firebase Initialization
**Delete:** `control_center/src/core/auth/firebase.js`
### 4.3 AuthContext Rewrite
**File:** [control_center/src/core/auth/AuthContext.jsx](control_center/src/core/auth/AuthContext.jsx)
New state:
- `accessToken` in memory (React state)
- `profile`
- `authStatus` = `loading | authed | unauthenticated`
New methods:
- `bootstrap()` — on mount, call `/internal/auth/refresh` (uses existing httpOnly cookie if present); set state
- `login(email, password)` — POST `/internal/auth/login`, server sets cookie + returns access token in body
- `logout()` — POST `/internal/auth/logout`, clear state
- `refreshAccessToken()` — used by API interceptor on 401
### 4.4 API Client Update
**File:** [control_center/src/core/api/api-client.js](control_center/src/core/api/api-client.js)
Replace `auth.currentUser.getIdToken()` with access token from AuthContext. On 401, call `refreshAccessToken()` and retry. All requests send `credentials: 'include'` (for cookie).
### 4.5 CORS Update
**File:** [backend/src/app.internal.js](backend/src/app.internal.js)
Ensure `@fastify/cors` config allows credentials from the CC origin (required for httpOnly cookie):
```js
await app.register(cors, {
origin: process.env.CC_ORIGIN,
credentials: true,
})
```
### 4.6 Login Page
**File:** [control_center/src/pages/login/LoginPage.jsx](control_center/src/pages/login/LoginPage.jsx) (or wherever the login page lives)
Update form to call `authContext.login(email, password)`. Error handling for lockout (15-min) and wrong-credentials.
### 4.7 CC User Management Page
**File:** existing users management page (likely `control_center/src/pages/users/UsersPage.jsx`)
Update create-user form:
- Add "Initial Password" field with "Generate" button (`crypto.randomUUID().slice(0,16)`)
- Submit calls `POST /internal/cc-user` with plain password; backend hashes
Add password-change UI:
- Self: account menu → "Change password" modal (current + new)
- Admin-forced: row action on user list → "Reset password" modal (new password only)
---
## Work Stream 5: Env & Config
### 5.1 Backend `.env.example`
Add:
```
AUTH_JWT_SECRET=
ACCESS_TOKEN_TTL_SECONDS=3600
REFRESH_TOKEN_TTL_DAYS=30
FAZPASS_API_KEY=
FAZPASS_BASE_URL=
FAZPASS_WEBHOOK_SECRET=
GOOGLE_OAUTH_CLIENT_IDS=
APPLE_SERVICES_ID=
APPLE_TEAM_ID=
APPLE_KEY_ID=
APPLE_PRIVATE_KEY=
ADMIN_EMAIL=admin@halobestie.com
ADMIN_PASSWORD=
CC_ORIGIN=http://localhost:5173
```
Remove:
```
FIREBASE_PROJECT_ID=
FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=
```
### 5.2 control_center `.env.example`
Remove:
```
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
```
### 5.3 Mobile Firebase Configs
- Keep `google-services.json` (Android) / `GoogleService-Info.plist` (iOS) — required by `firebase_core` + `firebase_messaging` for FCM
- Keep `firebase_options.dart` — only referenced for FCM
---
## 6. Implementation Order
| Step | What | Apps | Dependencies |
|---|---|---|---|
| **Prerequisites** | | | |
| 0 | Apple Dev account, Fazpass creds, env vars provisioned | Ops | — |
| **Work Stream 1: Backend** | | | |
| 1 | DB migration + constants + config service additions | Backend | Step 0 |
| 2 | Token service (JWT issue / verify / refresh rotation) | Backend | Step 1 |
| 3 | Password service (hash, verify, complexity) | Backend | — |
| 4 | Rewrite `authenticate` middleware (JWT-based) | Backend | Step 2 |
| 5 | Social identity service (Google + Apple JWKS verify) | Backend | — |
| 6 | OTP service (Fazpass send + verify + rate-limit) | Backend | Step 1, Fazpass docs |
| 7 | Auth service orchestrator (all sign-in flows) | Backend | Steps 2, 3, 5, 6 |
| 8 | Rewrite auth routes (public/client, public/mitra, internal/auth) | Backend | Step 7 |
| 9 | CC user provisioning + password change routes | Backend | Step 3 |
| 10 | Update all other routes to use `request.auth` | Backend | Step 4 |
| 11 | Rewrite WebSocket auth handshake | Backend | Step 2 |
| 12 | Isolate Firebase to FCM-only (`firebase.js``fcm.js`) | Backend | Step 10, 11 |
| 13 | Rewrite seed script | Backend | Step 3 |
| **Work Stream 2: client_app** | | | |
| 14 | Secure storage wrapper + auth notifier skeleton | client_app | Step 8 |
| 15 | API client interceptor (JWT attach + 401 refresh) | client_app | Step 14 |
| 16 | Phone OTP flow (request + verify + bootstrap anonymous) | client_app | Step 14 |
| 17 | Google sign-in wiring | client_app | Step 16 |
| 18 | Apple sign-in wiring | client_app | Step 17 |
| 19 | WebSocket auth handshake update | client_app | Step 15 |
| 20 | Remove all `FirebaseAuth.instance` references | client_app | Step 19 |
| **Work Stream 3: mitra_app** | | | |
| 21 | Secure storage + auth notifier (phone-only) | mitra_app | Step 8 |
| 22 | API client + WebSocket auth update | mitra_app | Step 21 |
| 23 | Remove `FirebaseAuth.instance` references | mitra_app | Step 22 |
| **Work Stream 4: control_center** | | | |
| 24 | AuthContext rewrite (in-memory access + cookie refresh) | control_center | Step 8 |
| 25 | API client update (credentials: include, 401 refresh) | control_center | Step 24 |
| 26 | CORS config to allow credentials | Backend + control_center | Step 24 |
| 27 | Login page rewrite | control_center | Step 24 |
| 28 | CC user management password UI | control_center | Step 9 |
| 29 | Remove `firebase` dependency + firebase.js | control_center | Step 27 |
| **Testing / Cleanup** | | | |
| 30 | E2E: anonymous → OTP → Google → Apple → refresh → logout (client_app) | All | Steps 120 |
| 31 | E2E: OTP → refresh → logout (mitra_app) | All | Steps 2123 |
| 32 | E2E: CC login → password change → lockout → logout | All | Steps 2429 |
| 33 | Regression: all Phase 3 / 3.1 / 3.2 / 3.3 flows still work with new auth | All | All above |
---
## 7. New Files
| File | Purpose |
|---|---|
| `backend/src/services/token.service.js` | JWT + refresh rotation + session storage |
| `backend/src/services/otp.service.js` | Fazpass integration + rate-limit enforcement |
| `backend/src/services/social-identity.service.js` | Google + Apple ID token verification |
| `backend/src/services/auth.service.js` | Sign-in flow orchestrator |
| `backend/src/services/password.service.js` | bcrypt + complexity validation |
| `backend/src/plugins/fcm.js` | Renamed from firebase.js, FCM-only |
| `client_app/lib/core/auth/token_storage.dart` | Secure storage wrapper |
| `mitra_app/lib/core/auth/token_storage.dart` | Secure storage wrapper |
## 8. Modified Files (Primary)
| File | Change |
|---|---|
| `backend/src/db/migrate.js` | Drop firebase_uid, add new columns, add auth_sessions + otp_requests tables, seed config keys |
| `backend/src/db/seed.js` | Bcrypt-based admin seed instead of Firebase |
| `backend/src/constants.js` | AuthProvider + OtpChannel |
| `backend/src/services/config.service.js` | OTP + CC lockout config getters/setters |
| `backend/src/services/customer.service.js` | Drop firebase_uid lookup; add google_sub/apple_sub/phone lookups |
| `backend/src/services/mitra.service.js` | Drop firebase_uid lookup; phone-based lookup |
| `backend/src/services/cc-user.service.js` | Drop firebase_uid; email + password_hash based |
| `backend/src/services/notification.service.js` | Use FCM-only import from new fcm.js |
| `backend/src/plugins/auth.js` | JWT-based middleware |
| `backend/src/plugins/websocket.js` | JWT auth in WS handshake |
| `backend/src/routes/public/client.auth.routes.js` | Full rewrite |
| `backend/src/routes/public/mitra.auth.routes.js` | Full rewrite |
| `backend/src/routes/internal/auth.routes.js` | Full rewrite |
| `backend/src/routes/internal/cc-user.routes.js` | Password endpoints + plain-password provisioning |
| All route files currently using `request.firebaseUser.uid` | Switch to `request.auth.userId` / `request.auth.userType` |
| `backend/src/app.internal.js` | CORS with credentials for cookie |
| `backend/package.json` | Add jsonwebtoken, bcrypt, google-auth-library, apple-signin-auth |
| `client_app/pubspec.yaml` | Remove firebase_auth; add flutter_secure_storage |
| `client_app/lib/main.dart` | Remove FirebaseAuth listener; call authNotifier.bootstrap |
| `client_app/lib/core/auth/auth_notifier.dart` | Full rewrite |
| `client_app/lib/core/api/api_client.dart` | JWT interceptor |
| `client_app/lib/core/chat/chat_notifier.dart` | JWT in WS handshake |
| All client_app auth screens | Call new notifier methods |
| `mitra_app/pubspec.yaml` | Remove firebase_auth; add flutter_secure_storage |
| `mitra_app/lib/main.dart` | Remove FirebaseAuth listener |
| `mitra_app/lib/core/auth/auth_notifier.dart` | Full rewrite (phone-only) |
| `mitra_app/lib/core/api/api_client.dart` | JWT interceptor |
| `mitra_app/lib/core/chat/mitra_chat_notifier.dart` | JWT in WS handshake |
| `mitra_app/lib/core/chat/chat_request_notifier.dart` | JWT in WS handshake |
| All mitra_app auth screens | Call new notifier methods |
| `control_center/package.json` | Remove firebase |
| `control_center/src/core/auth/AuthContext.jsx` | Full rewrite |
| `control_center/src/core/api/api-client.js` | In-memory token + cookie refresh |
| `control_center/src/pages/login/LoginPage.jsx` | Rewrite |
| `control_center/src/pages/users/UsersPage.jsx` | Password provisioning UI |
## 9. Deleted Files
| File | Reason |
|---|---|
| `control_center/src/core/auth/firebase.js` | No longer needed |
## 10. Env File Changes
See Section 5 above.
---
## 11. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Fazpass API shape assumed wrong → service rewrite mid-implementation | Block on real docs before Step 6; mark `otp.service.js` as stub until then |
| Apple Developer setup blocks iOS testing | Flagged as Prerequisite 0; other flows (Google, phone) still testable on Android |
| CC cookie auth breaks with cross-origin CC ↔ backend | Set `SameSite=None; Secure` on cookie in prod; use `sameSite=lax` in dev; CORS `credentials: true` |
| JWT secret leak compromises all sessions | Env var stored in secret manager; rotation procedure documented (deferred impl) |
| Existing users locked out after cutover | Dev DB wiped by agreement; production migration explicitly deferred |
| Refresh token theft | Rotation-on-use means stolen token becomes invalid on next legitimate use; device_info audit trail |
| bcrypt cost 12 is slow on low-end hardware | Cost 12 takes ~250ms on modern CPU; acceptable for OTP/login frequency; revisit if latency spikes |
| Anonymous refresh token loss (uninstall / secure storage wipe) | Accepted — reinstall = fresh anonymous session + lost chat history (matches Firebase behavior today) |
| Google/Apple sign-in fails on first launch after install | Both native SDKs handle this; backend returns 401 with clear error code; client shows "Try again" |
| Fazpass rate-limit separate from our rate-limit | Our backend enforces first (configurable); Fazpass enforces second (provider's own); log both distinctly |
| Concurrent OTP requests for same phone | DB row per request; rate-limit check before Fazpass call prevents double-charge |
| Token claim drift (adding fields later) | JWT is versionless; all claims read via property access, missing claims tolerated in verifier |
---
## 12. Testing Checklist
**Backend unit (per service):**
- [ ] `token.service.js`: issue → verify → refresh rotation → logout → verify fails
- [ ] `password.service.js`: hash → verify → complexity rejections (short, missing digit, missing case)
- [ ] `social-identity.service.js`: mocked Google + Apple valid tokens verified; invalid signature rejected; wrong audience rejected
- [ ] `otp.service.js`: rate-limit throws; resend cooldown honored; verify increments attempts; stub Fazpass with a mock
**Backend integration:**
- [ ] Anonymous → phone OTP → upgrade (same customer row)
- [ ] Anonymous → Google → upgrade (same customer row)
- [ ] Anonymous → Google → 409 if google_sub already linked elsewhere
- [ ] OTP resend within 60s → 429
- [ ] 4 OTP requests from same phone in an hour → 429
- [ ] CC 5 failed logins → lockout 15 min; 6th attempt even with correct password → 423/429
- [ ] Refresh token reuse (after rotation) → rejected
- [ ] Logout → refresh rejected, access expires naturally
**client_app E2E:**
- [ ] First launch → anonymous session created → can request chat
- [ ] Request OTP → verify → upgraded, chat history preserved
- [ ] Google sign-in (fresh customer)
- [ ] Apple sign-in (fresh customer)
- [ ] Token refresh transparent to user (break access token early to test)
- [ ] Logout → returns to splash → anonymous auto-created
- [ ] Kill app with active chat → restart → session restored via refresh
**mitra_app E2E:**
- [ ] OTP request + verify → new mitra row created
- [ ] Token refresh transparent
- [ ] Chat request WebSocket auth works
- [ ] Logout
**control_center E2E:**
- [ ] Seeded super admin can log in
- [ ] Create second admin with initial password → new admin logs in
- [ ] Self-service password change
- [ ] Admin-forced password reset
- [ ] Refresh cookie persists across browser reloads
- [ ] Logout clears cookie + state
**Regression:**
- [ ] Phase 3 flows (chat, extension, closure) work end-to-end with new auth
- [ ] Phase 3.2 overlay still works with JWT-based WS auth
- [ ] Phase 3.3 topic sensitivity flow still works
**Negative:**
- [ ] Tampered JWT → 401
- [ ] Expired JWT → 401 → client auto-refreshes
- [ ] Refresh token from another device/user → 401
- [ ] Missing `Authorization` header on protected route → 401
- [ ] Customer calling mitra-only endpoint with valid customer JWT → 403

417
requirement/phase3.4.md Normal file
View File

@@ -0,0 +1,417 @@
# PRD: Self-Managed Authentication
# Overview
**Goal:** Replace Firebase Auth with a self-managed authentication system across all apps, using Fazpass for SMS/WhatsApp OTP delivery and direct verification of Google/Apple ID tokens for social login.
**Success looks like:** Customers, mitras, and control center admins can all authenticate through our own backend without any dependency on Firebase Auth. User identity is anchored in our database, session tokens are issued and revoked by our backend, and Firebase Admin SDK is fully removed from the backend. FCM push notifications continue to work (Firebase Cloud Messaging is kept).
**Affects:** `backend`, `client_app`, `mitra_app`, `control_center`
## Background
Firebase Auth currently handles:
- Phone OTP for customers and mitras
- Google and Apple social login for customers
- Email/password for control center admins
- Anonymous sessions for customers
- JWT issuance and verification
Moving off Firebase gives us direct control over:
- Session management (revocation, multi-device, TTL tuning)
- OTP delivery (Fazpass for WhatsApp + SMS, better Indonesian coverage than Firebase phone auth)
- User data (no user identity locked inside Firebase)
- Cost (Firebase phone auth is expensive at scale)
- Branding (fully native flows, no Firebase-branded fallback screens)
FCM (Firebase Cloud Messaging) is separate from Firebase Auth and **stays** — it handles push notifications and is not affected by this phase.
---
# Functional Requirements
## 1. Anonymous Customer Flow
### Trigger
- App launches for the first time OR local refresh token is missing/expired.
### Behavior
- App calls `POST /api/shared/auth/anonymous` with no body, no auth.
- Backend creates a new `customers` row with no identity (phone, google_sub, apple_sub all null) and a generated `display_name` (e.g., "Teman Anonim #XXXX").
- Backend creates an `auth_sessions` row and returns `{ access_token, refresh_token }`.
- App stores refresh token in secure storage (Keychain / Keystore).
- All subsequent requests use the access token.
### Upgrade to Authenticated
- When the user chooses a real identity (phone / Google / Apple), the existing anonymous `customers` row gets its identity columns populated (phone / google_sub / apple_sub / email).
- Tokens are rotated — a new access + refresh token pair is issued on identity upgrade.
- If the identity is **already linked to another customer**, backend returns **409 CONFLICT** (reject-on-existing). Merging is deferred (see Out of Scope).
---
## 2. Phone OTP Flow (Customer + Mitra)
### Request OTP
- `POST /api/{client|mitra}/auth/otp/request` with body `{ phone: "+628..." }`
- Backend:
- Validates phone format (E.164)
- Checks rate limits (see Section 8)
- Calls Fazpass API to send OTP via configured channel (default: WhatsApp with SMS fallback)
- Stores the Fazpass request reference (not the OTP code itself — Fazpass holds it) in a new `otp_requests` table
- Returns `{ otp_request_id, channel_used, expires_at }`
### Verify OTP
- `POST /api/{client|mitra}/auth/otp/verify` with `{ otp_request_id, code }`
- Backend:
- Looks up `otp_requests` row; validates not expired, not already used, attempts under limit
- Calls Fazpass verify API
- If valid:
- Look up existing `customers` (or `mitras`) by phone
- If found: issue tokens against that row (for customer, this is the "upgrade anonymous" path if the current session is anonymous — see Section 1)
- If not found: create a new row keyed by phone
- Issues `{ access_token, refresh_token }` + user profile
### Resend
- Same endpoint as Request OTP. Rate limiter enforces 60s cooldown (see Section 8).
---
## 3. Google Sign-In (Customer Only)
### Flow
- Flutter app uses the existing `google_sign_in` package (a Google library, not a Firebase library — survives Firebase removal).
- `GoogleSignIn().signIn()` → returns a Google ID token (JWT signed by Google).
- App sends `POST /api/client/auth/google` with `{ id_token }`.
- Backend:
- Verifies the token against Google's JWKS (`https://www.googleapis.com/oauth2/v3/certs`) using `google-auth-library`
- Validates audience matches our OAuth client ID (configured per-platform: Android, iOS)
- Extracts `sub` (Google user ID), `email`, `name`, `email_verified`
- Look up existing `customers` by `google_sub`:
- If found: issue tokens against that row
- If not found and caller has anonymous session: upgrade anonymous row by setting `google_sub` + `email` + `display_name`
- If not found and no anonymous session: create a new customer row
- If the Google sub is already linked to a different customer and caller is anonymous: return 409 (reject-on-existing)
- Returns `{ access_token, refresh_token, profile }`
### OAuth Client IDs
- Separate client IDs per platform (Android / iOS) must be configured in Google Cloud Console.
- Configured via env vars on the backend: `GOOGLE_OAUTH_CLIENT_IDS` (comma-separated list, all validated as valid audiences).
---
## 4. Apple Sign-In (Customer Only)
### Prerequisites (External)
- **Apple Developer account** ($99/year) — required.
- Services ID created in Apple Developer portal (e.g., `com.halobestie.client.signin`)
- Private key (`.p8`) generated with Key ID
- Team ID noted
**This setup is a hard blocker for end-to-end testing.** Backend code can be written and dry-verified without it; runtime flow cannot function on iOS until this is done.
### Flow
- Flutter app uses `sign_in_with_apple` package.
- `SignInWithApple.getAppleIDCredential()` → returns Apple's ID token.
- App sends `POST /api/client/auth/apple` with `{ id_token, authorization_code? }`.
- Backend:
- Verifies token against Apple's JWKS (`https://appleid.apple.com/auth/keys`) using `apple-signin-auth` or manual JWT verification
- Validates audience matches Services ID
- Extracts `sub` (Apple user ID), `email` (only on first sign-in; subsequent sign-ins don't include it)
- Customer lookup/creation/anonymous-upgrade flow identical to Google
- Returns `{ access_token, refresh_token, profile }`
### Email Handling Quirk
- Apple only returns `email` the first time a user signs in with your app. Subsequent sign-ins omit it.
- We must persist `email` on first sign-in; don't rely on getting it again.
### App Store Policy
- Per Apple guidelines, any iOS app offering third-party social login (Google) **must also offer Sign in with Apple**. So Apple is required on iOS since client_app has Google.
---
## 5. Mitra Authentication
- Mitras use **phone OTP only** (Section 2). No Google, no Apple, no anonymous.
- Existing mitra auth flow wraps the same Fazpass integration behind `/api/mitra/auth/otp/request` and `/api/mitra/auth/otp/verify`.
---
## 6. Control Center Email/Password
### Login
- `POST /internal/auth/login` with `{ email, password }`
- Backend:
- Looks up `control_center_users` by email
- Compares password against `password_hash` using bcrypt
- Checks brute-force lockout state (see Section 8)
- On success: issues `{ access_token, refresh_token, profile }`
- On failure: increments failure counter; after 5 failures, 15-minute lockout
### First Super-Admin Seeding
- Updated `backend/src/db/seed.js` reads `ADMIN_EMAIL` + `ADMIN_PASSWORD` env vars, bcrypt-hashes the password, inserts a single `control_center_users` row with super-admin role.
- Replaces the existing Firebase `admin.auth().createUser()` call.
### New Admin Provisioning (CC UI)
- Super-admin creates new CC users via existing CC "Users" page.
- Form adds an "Initial Password" field (with a "Generate" button that creates a 16-char random).
- Backend `POST /internal/cc-user` accepts `{ email, display_name, role_id, password }`, hashes password, inserts row.
### Password Change (Self-Service)
- `PATCH /internal/cc-user/me/password` with `{ current_password, new_password }`
- Backend verifies `current_password` against stored hash, then replaces hash with `bcrypt.hash(new_password, 12)`
### Password Change (Admin-Forced)
- `PATCH /internal/cc-user/:id/password` with `{ new_password }`
- Requires super-admin role
- Same bcrypt flow
### Password Complexity Rules
- Minimum 8 characters
- At least 1 digit
- At least 1 uppercase letter
- At least 1 lowercase letter
- Enforced server-side on create + change endpoints
### Password Hashing
- **bcrypt with cost factor 12**
- Salt is embedded in the hash (bcrypt handles automatically; no separate column needed)
- Stored in `control_center_users.password_hash VARCHAR(60)`
---
## 7. Token Strategy
### Access Token (JWT, HS256)
- **TTL: 1 hour**
- **Signed with**: `AUTH_JWT_SECRET` env var (strong random, min 32 bytes)
- **Claims**:
- `sub` — user ID
- `user_type``customer` | `mitra` | `cc_user`
- `session_id` — PK of the `auth_sessions` row (used for future Valkey-based revocation; see Section 7.3)
- `iat`, `exp` — standard JWT fields
- **Verification**: backend validates signature and expiry on every authenticated request — no DB lookup on happy path
### Refresh Token (Opaque)
- **Format**: 32-byte random, base64url encoded
- **TTL: 30 days**
- **Rotation**: every time the refresh token is used, a new refresh token is issued and the old one is invalidated
- **Storage**:
- Client: secure storage (Keychain / Keystore on mobile, `httpOnly` cookie on CC — see note below)
- Server: bcrypt-hashed in `auth_sessions.refresh_token_hash` (never raw)
### Control Center Token Storage
- Browser-based, so JWT access token lives in memory (JS variable in AuthContext) — **not** localStorage (XSS risk)
- Refresh token: httpOnly secure cookie on the CC domain. Browser auto-sends; JS can't read.
- Automatic refresh when access token expires (via 401 interceptor)
### Revocation (Now)
- **Logout**: `DELETE FROM auth_sessions WHERE id = :session_id` — refresh token becomes unusable; access token dies within 1 hour
- **Admin ban mitra**: delete all `auth_sessions` rows for that mitra — same 1-hour window for active access tokens
- Accepted tradeoff: **up to 1 hour between revocation and full session death**
### Revocation (Future, Pre-wired)
- `session_id` claim in JWT enables Valkey-based instant revocation without changing token shape
- Future enhancement: add `SISMEMBER revoked_sessions <session_id>` check to the authenticate middleware
- Mentioned in the plan as a deferred enhancement — not implemented in Phase 3.4
### Multi-Device Sessions
- Each login / OTP verify / social verify = new `auth_sessions` row
- Logging in on device B does not invalidate device A
- Logout only affects the calling device
### Refresh Endpoint
- `POST /api/shared/auth/refresh` with `{ refresh_token }`
- Backend: look up session by token hash, verify not expired, rotate refresh token, issue new access token, return both
### Logout Endpoint
- `POST /api/shared/auth/logout` with `{ refresh_token }` (authenticated)
- Backend: delete the matching `auth_sessions` row
---
## 8. Security
### OTP Rate Limits (Configurable via `app_config`)
- `otp_max_per_phone_per_hour` — default **3**
- `otp_max_per_ip_per_hour` — default **10**
- Exceeding returns **429** with `Retry-After` header
### OTP Resend Cooldown
- Same phone cannot request another OTP within **60 seconds** of the last request
### OTP Verification Attempts
- Max **5 wrong code attempts per OTP request** before invalidating the request (new OTP required)
### Control Center Login Brute-Force
- Max **5 failed password attempts per email per 15 minutes**
- 6th failure locks the account for **15 minutes** (tracked in `control_center_users.lockout_until`)
- Failed attempts counter resets on successful login
### Session Fingerprinting
- On every auth_sessions creation, record `user_agent` and `ip` in a `device_info JSONB` column
- Not enforced for security; visible in control center for audit/support
### JWT Secret Rotation
- **Out of scope for this phase**: single `AUTH_JWT_SECRET` env var
- Documentation will note the rotation procedure (dual-secret window) but implementation is deferred
### HTTPS Only
- All auth endpoints enforce HTTPS in production (assumed via existing infra — Cloud Run + Nginx)
---
## 9. Data Model
### New Table: `auth_sessions`
```
id UUID PK
user_type VARCHAR(16) NOT NULL -- 'customer' | 'mitra' | 'cc_user'
user_id UUID NOT NULL
refresh_token_hash VARCHAR(60) NOT NULL -- bcrypt hash of refresh token
device_info JSONB -- { user_agent, ip, platform? }
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_at TIMESTAMPTZ NOT NULL -- created_at + 30 days
revoked_at TIMESTAMPTZ -- null if active
```
Indexes: `(user_type, user_id)`, `(expires_at)` for cleanup.
### New Table: `otp_requests`
```
id UUID PK
phone VARCHAR(20) NOT NULL
fazpass_reference VARCHAR(255) NOT NULL -- Fazpass's OTP session ID
channel VARCHAR(16) -- 'whatsapp' | 'sms'
attempts INT NOT NULL DEFAULT 0
used_at TIMESTAMPTZ
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_at TIMESTAMPTZ NOT NULL
```
Index: `(phone, created_at)` for rate-limit lookups.
### Schema Changes to Existing Tables
**`customers`**
- DROP `firebase_uid`
- ADD `phone VARCHAR(20) UNIQUE` (already exists, keep)
- ADD `email VARCHAR(255)` (nullable, populated from Google/Apple)
- ADD `google_sub VARCHAR(255) UNIQUE` (nullable)
- ADD `apple_sub VARCHAR(255) UNIQUE` (nullable)
**`mitras`**
- DROP `firebase_uid`
**`control_center_users`**
- DROP `firebase_uid`
- ADD `password_hash VARCHAR(60) NOT NULL`
- ADD `failed_login_count INT NOT NULL DEFAULT 0`
- ADD `lockout_until TIMESTAMPTZ` (nullable)
### New `app_config` Keys
- `otp_max_per_phone_per_hour` (default `3`)
- `otp_max_per_ip_per_hour` (default `10`)
- `otp_resend_cooldown_seconds` (default `60`)
- `otp_verify_max_attempts` (default `5`)
- `cc_login_max_attempts` (default `5`)
- `cc_login_lockout_minutes` (default `15`)
---
## 10. Dependencies to Add / Remove
### Backend
**Add**:
- `jsonwebtoken` (JWT signing/verification)
- `bcrypt` (password + refresh token hashing)
- `google-auth-library` (Google ID token verification)
- `apple-signin-auth` or `jsonwebtoken` + JWKS fetch (Apple ID token verification)
- Fazpass SDK (if they ship one) or direct HTTP client (axios/undici)
**Remove**:
- `firebase-admin`
**Keep**:
- Firebase Cloud Messaging via `firebase-admin`**wait** — if we remove `firebase-admin`, how does FCM work? The same package handles both Auth and Messaging. Two options:
- (a) Keep `firebase-admin` but only use its `messaging()` API; never touch `auth()` or the ID token verification functions
- (b) Switch to the lower-level FCM HTTP v1 API directly (no SDK)
- **Recommendation**: (a) — simpler, still removes the Auth dependency at runtime.
### client_app (Flutter)
**Remove**:
- `firebase_auth`
- `firebase_core` (if no other Firebase features remain — but FCM needs it, so keep)
**Keep**:
- `firebase_core`, `firebase_messaging` (for FCM)
- `google_sign_in` (standalone Google library)
- `sign_in_with_apple` (standalone Apple library)
- `flutter_secure_storage` (add if not present — refresh token storage)
**Remove config**:
- `firebase_options.dart` — only keep if `firebase_core` is still in use for FCM; otherwise remove
### mitra_app (Flutter)
**Remove**:
- `firebase_auth`
**Keep**:
- `firebase_core`, `firebase_messaging` (for FCM)
- `flutter_secure_storage` (refresh token)
### control_center (React)
**Remove**:
- `firebase`
**Add**:
- None — the existing Axios + cookies setup handles auth.
---
## 11. Prerequisites (External, Non-Code)
- [ ] **Apple Developer account** active ($99/year) — required for Sign in with Apple
- [ ] **Apple Services ID + private key + Team ID + Key ID** — required for backend verification
- [ ] **Google OAuth Client IDs** per platform (Android / iOS) — survive Firebase removal, likely already exist
- [ ] **Fazpass API key + webhook setup** (if applicable) — required for OTP delivery
- [ ] **New env vars provisioned** in all environments (dev, staging, prod):
- `AUTH_JWT_SECRET`
- `FAZPASS_API_KEY`, `FAZPASS_BASE_URL`
- `GOOGLE_OAUTH_CLIENT_IDS`
- `APPLE_SERVICES_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY` (PEM contents of the `.p8` file)
- `ADMIN_EMAIL`, `ADMIN_PASSWORD` (for CC seeding)
---
## 12. Out of Scope for Phase 3.4
- **Password reset for control center** — explicitly deferred (admin-forced reset is the only recovery path)
- **Email delivery infrastructure** (SMTP / Sendgrid / SES) — no emails sent in this phase
- **Cross-app data migration** from the other existing production app — handled separately
- **Merge-on-link** for social login (reject-on-existing for now; merge added later)
- **Valkey-based instant revocation** — pre-wired via `session_id` claim, implementation deferred
- **JWT secret rotation procedure** — documented only, not implemented
- **2FA / MFA** for control center admins
- **Phone number change** flow (re-linking phone to an existing account)
- **Account deletion / soft-delete flow** — separate privacy/compliance work
---
## 13. Prerequisites Before Implementing
Before writing a single line of code:
1. **Apple Developer account** must be purchased and Sign in with Apple configured (Services ID + `.p8` key)
2. **Fazpass account** must be provisioned with API credentials
3. Agreement that the existing halobestie-clone dev database can be wiped and users re-registered (no migration within this phase; cross-app user migration is a separate task)
---
# Tech Stack
- **Backend**: Fastify (existing), PostgreSQL (existing), new deps: `jsonwebtoken`, `bcrypt`, `google-auth-library`, `apple-signin-auth`, Fazpass HTTP integration
- **client_app**: Flutter, `google_sign_in`, `sign_in_with_apple`, `flutter_secure_storage`
- **mitra_app**: Flutter, `flutter_secure_storage`
- **control_center**: React + Vite, httpOnly cookies for refresh tokens
- **Kept**: `firebase-admin` (Messaging only), `firebase_core`, `firebase_messaging` for FCM push