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:
247
backend/src/services/pairing.service.js
Normal file
247
backend/src/services/pairing.service.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user