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,149 @@
import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
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,
m.display_name AS mitra_display_name
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN ('active', 'pending_payment')
ORDER BY cs.created_at DESC LIMIT 1
`
return session
}
export const getActiveSessionsByMitra = async (mitraId) => {
const sessions = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
c.display_name AS customer_display_name
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
WHERE cs.mitra_id = ${mitraId}
AND cs.status IN ('active', 'pending_payment')
ORDER BY cs.created_at DESC
`
return sessions
}
export const endSession = async (sessionId, endedBy) => {
const [session] = await sql`
UPDATE chat_sessions
SET status = 'completed', ended_at = NOW(), ended_by = ${endedBy}
WHERE id = ${sessionId} AND status IN ('active', 'pending_payment')
RETURNING id, customer_id, mitra_id, status, ended_at, ended_by
`
if (!session) {
throw Object.assign(new Error('Session not found or already ended'), {
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
})
}
// Notify both parties
await publish(`session:${sessionId}:status`, {
type: 'session_ended',
session_id: sessionId,
ended_by: endedBy,
})
return session
}
export const rerouteSession = async (sessionId, newMitraId) => {
// Get current session
const [current] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
WHERE id = ${sessionId} AND status IN ('active', 'pending_payment')
`
if (!current) {
throw Object.assign(new Error('Session not found or not active'), {
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
})
}
// Verify new mitra is online
const [newMitraStatus] = await sql`
SELECT is_online FROM mitra_online_status WHERE mitra_id = ${newMitraId}
`
if (!newMitraStatus?.is_online) {
throw Object.assign(new Error('Target mitra is not online'), {
code: 'MITRA_NOT_ONLINE', statusCode: 422,
})
}
const oldMitraId = current.mitra_id
// Update session with new mitra (forced assignment)
const [session] = await sql`
UPDATE chat_sessions
SET mitra_id = ${newMitraId}
WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status
`
const [newMitra] = await sql`
SELECT display_name FROM mitras WHERE id = ${newMitraId}
`
// Notify customer about reroute
await publish(`session:${sessionId}:status`, {
type: 'rerouted',
session_id: sessionId,
mitra_display_name: newMitra.display_name,
})
// Notify old mitra session removed
if (oldMitraId) {
await publish(`mitra:${oldMitraId}:requests`, {
type: 'session_rerouted',
session_id: sessionId,
})
}
// Notify new mitra about new session
await publish(`mitra:${newMitraId}:requests`, {
type: 'session_assigned',
session_id: sessionId,
})
return session
}
export const listSessions = async ({ page = 1, limit = 20, status } = {}) => {
const offset = (page - 1) * limit
const conditions = status
? sql`WHERE cs.status = ${status}`
: sql``
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,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
LEFT JOIN mitras m ON m.id = cs.mitra_id
${conditions}
ORDER BY cs.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`
const [{ count }] = await sql`SELECT COUNT(*) FROM chat_sessions cs ${conditions}`
return { items, total: Number(count), page, limit }
}
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,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.id = ${sessionId}
`
return session
}