Phase 2 scaffold: mitra online status & pairing logic

Add mitra online/offline status with heartbeat-based auto-offline,
customer-mitra pairing via Valkey pub/sub blast, session management,
and control center dashboard with real-time stats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 23:17:49 +08:00
parent a7a2a32d27
commit d668112edd
44 changed files with 2800 additions and 80 deletions

View File

@@ -0,0 +1,247 @@
import { getDb } from '../db/client.js'
import { getMaxCustomersPerMitra } from './config.service.js'
import { publish } from '../plugins/valkey.js'
const sql = getDb()
// Timeout map for active pairing requests (sessionId → timeoutId)
const pairingTimeouts = new Map()
export const findAvailableMitras = async () => {
const { max_customers_per_mitra } = await getMaxCustomersPerMitra()
const mitras = await sql`
SELECT m.id, m.display_name
FROM mitras m
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
WHERE m.is_active = true
AND s.is_online = true
AND (
SELECT COUNT(*) FROM chat_sessions cs
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')
) < ${max_customers_per_mitra}
`
return mitras
}
export const createPairingRequest = async (customerId) => {
// Check for existing active session or request
const [existing] = await sql`
SELECT id, status FROM chat_sessions
WHERE customer_id = ${customerId}
AND status IN ('searching', 'pending_acceptance', 'pending_payment', 'active')
`
if (existing) {
throw Object.assign(new Error('Customer already has an active session or request'), {
code: 'ALREADY_ACTIVE', statusCode: 409,
})
}
const availableMitras = await findAvailableMitras()
if (availableMitras.length === 0) {
throw Object.assign(new Error('No bestie available'), {
code: 'NO_MITRA_AVAILABLE', statusCode: 404,
})
}
// Create session
const [session] = await sql`
INSERT INTO chat_sessions (customer_id, status)
VALUES (${customerId}, 'pending_acceptance')
RETURNING id, customer_id, status, created_at
`
// Create notifications for all available mitras
for (const mitra of availableMitras) {
await sql`
INSERT INTO chat_request_notifications (session_id, mitra_id)
VALUES (${session.id}, ${mitra.id})
`
// Publish to mitra's channel
await publish(`mitra:${mitra.id}:requests`, {
type: 'chat_request',
session_id: session.id,
created_at: session.created_at,
})
}
// Start 60s timeout
const timeoutId = setTimeout(async () => {
try {
await expirePairingRequest(session.id)
} catch (_) {}
}, 60_000)
pairingTimeouts.set(session.id, timeoutId)
return session
}
export const acceptPairingRequest = async (sessionId, mitraId) => {
// Use a transaction-like approach: update only if status is still pending_acceptance
const [session] = await sql`
UPDATE chat_sessions
SET mitra_id = ${mitraId}, status = 'pending_payment', paired_at = NOW()
WHERE id = ${sessionId} AND status = 'pending_acceptance' AND mitra_id IS NULL
RETURNING id, customer_id, mitra_id, status, paired_at
`
if (!session) {
throw Object.assign(new Error('Request already accepted or expired'), {
code: 'REQUEST_UNAVAILABLE', statusCode: 409,
})
}
// Mark this mitra's notification as accepted
await sql`
UPDATE chat_request_notifications
SET response = 'accepted', responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId}
`
// Mark other mitras' notifications as ignored
await sql`
UPDATE chat_request_notifications
SET response = 'ignored', responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL
`
// Clear timeout
const timeoutId = pairingTimeouts.get(sessionId)
if (timeoutId) {
clearTimeout(timeoutId)
pairingTimeouts.delete(sessionId)
}
// Auto-skip payment for now: move to active
const [activeSession] = await sql`
UPDATE chat_sessions SET status = 'active'
WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status, paired_at
`
// Get mitra display name for customer notification
const [mitra] = await sql`
SELECT display_name FROM mitras WHERE id = ${mitraId}
`
// Notify customer
await publish(`session:${sessionId}:status`, {
type: 'paired',
session_id: sessionId,
mitra_display_name: mitra.display_name,
status: 'active',
})
// Notify other mitras to dismiss the request
const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId}
`
for (const n of notifications) {
await publish(`mitra:${n.mitra_id}:requests`, {
type: 'chat_request_closed',
session_id: sessionId,
})
}
return activeSession
}
export const declinePairingRequest = async (sessionId, mitraId) => {
await sql`
UPDATE chat_request_notifications
SET response = 'declined', responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL
`
}
export const cancelPairingRequest = async (sessionId, customerId) => {
const [session] = await sql`
UPDATE chat_sessions
SET status = 'cancelled'
WHERE id = ${sessionId} AND customer_id = ${customerId}
AND status IN ('searching', 'pending_acceptance')
RETURNING id, status
`
if (!session) {
throw Object.assign(new Error('Cannot cancel this request'), {
code: 'CANNOT_CANCEL', statusCode: 409,
})
}
// Clear timeout
const timeoutId = pairingTimeouts.get(sessionId)
if (timeoutId) {
clearTimeout(timeoutId)
pairingTimeouts.delete(sessionId)
}
// Mark all notifications as ignored
await sql`
UPDATE chat_request_notifications
SET response = 'ignored', responded_at = NOW()
WHERE session_id = ${sessionId} AND response IS NULL
`
// Notify mitras to dismiss
const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
`
for (const n of notifications) {
await publish(`mitra:${n.mitra_id}:requests`, {
type: 'chat_request_closed',
session_id: sessionId,
})
}
return session
}
export const expirePairingRequest = async (sessionId) => {
const [session] = await sql`
UPDATE chat_sessions
SET status = 'expired'
WHERE id = ${sessionId} AND status = 'pending_acceptance'
RETURNING id, customer_id, status
`
if (!session) return null
pairingTimeouts.delete(sessionId)
// Mark all pending notifications as ignored
await sql`
UPDATE chat_request_notifications
SET response = 'ignored', responded_at = NOW()
WHERE session_id = ${sessionId} AND response IS NULL
`
// Notify customer
await publish(`session:${sessionId}:status`, {
type: 'expired',
session_id: sessionId,
})
// Notify mitras to dismiss
const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
`
for (const n of notifications) {
await publish(`mitra:${n.mitra_id}:requests`, {
type: 'chat_request_closed',
session_id: sessionId,
})
}
return session
}
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,
m.display_name AS mitra_display_name
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.id = ${sessionId}
`
return session
}