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:
@@ -1,6 +1,6 @@
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
|
||||
import { getAnonymityConfig, setAnonymityConfig } from '../../services/config.service.js'
|
||||
import { getAnonymityConfig, setAnonymityConfig, getMaxCustomersPerMitra, setMaxCustomersPerMitra } from '../../services/config.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
||||
@@ -26,4 +26,22 @@ export const internalConfigRoutes = async (app) => {
|
||||
const config = await setAnonymityConfig(anonymity_enabled)
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
app.get('/max-customers-per-mitra', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const config = await getMaxCustomersPerMitra()
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
app.patch('/max-customers-per-mitra', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { max_customers_per_mitra } = request.body ?? {}
|
||||
if (typeof max_customers_per_mitra !== 'number' || max_customers_per_mitra < 1) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'max_customers_per_mitra must be a positive number' } })
|
||||
}
|
||||
const config = await setMaxCustomersPerMitra(max_customers_per_mitra)
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
|
||||
import { createMitra, listMitras, updateMitraStatus } from '../../services/mitra.service.js'
|
||||
import { getOnlineMitras, getOnlineLogs } from '../../services/mitra-status.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
||||
@@ -42,4 +43,19 @@ export const mitraManagementRoutes = async (app) => {
|
||||
const mitra = await updateMitraStatus(request.params.id, is_active)
|
||||
return reply.send({ success: true, data: mitra })
|
||||
})
|
||||
|
||||
app.get('/online', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const mitras = await getOnlineMitras()
|
||||
return reply.send({ success: true, data: mitras })
|
||||
})
|
||||
|
||||
app.get('/:id/online-logs', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const { page = 1, limit = 50 } = request.query
|
||||
const result = await getOnlineLogs(request.params.id, { page: Number(page), limit: Number(limit) })
|
||||
return reply.send({ success: true, data: result })
|
||||
})
|
||||
}
|
||||
|
||||
48
backend/src/routes/internal/session.routes.js
Normal file
48
backend/src/routes/internal/session.routes.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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 { getDashboardStats } from '../../services/dashboard.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
||||
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
|
||||
request.ccUser = user
|
||||
}
|
||||
|
||||
export const sessionManagementRoutes = async (app) => {
|
||||
app.get('/dashboard/stats', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const stats = await getDashboardStats()
|
||||
return reply.send({ success: true, data: stats })
|
||||
})
|
||||
|
||||
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 })
|
||||
return reply.send({ success: true, data: result })
|
||||
})
|
||||
|
||||
app.get('/:sessionId', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const session = await getSessionById(request.params.sessionId)
|
||||
if (!session) {
|
||||
return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } })
|
||||
}
|
||||
return reply.send({ success: true, data: session })
|
||||
})
|
||||
|
||||
app.post('/:sessionId/reroute', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { new_mitra_id } = request.body ?? {}
|
||||
if (!new_mitra_id) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'new_mitra_id is required' } })
|
||||
}
|
||||
const session = await rerouteSession(request.params.sessionId, new_mitra_id)
|
||||
return reply.send({ success: true, data: session })
|
||||
})
|
||||
}
|
||||
75
backend/src/routes/public/client.chat.routes.js
Normal file
75
backend/src/routes/public/client.chat.routes.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
|
||||
import { createPairingRequest, cancelPairingRequest, getSessionStatus } from '../../services/pairing.service.js'
|
||||
import { getActiveSessionByCustomer, endSession } from '../../services/session.service.js'
|
||||
import { subscribe } from '../../plugins/valkey.js'
|
||||
|
||||
const resolveCustomer = async (request, reply) => {
|
||||
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
|
||||
if (!customer) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' },
|
||||
})
|
||||
}
|
||||
request.customer = customer
|
||||
}
|
||||
|
||||
export const clientChatRoutes = async (app) => {
|
||||
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const session = await createPairingRequest(request.customer.id)
|
||||
return reply.code(201).send({ success: true, data: session })
|
||||
})
|
||||
|
||||
app.get('/request/:sessionId/status', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const { sessionId } = request.params
|
||||
|
||||
// SSE stream for real-time status updates
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
// Send current status immediately
|
||||
const current = await getSessionStatus(sessionId)
|
||||
if (current) {
|
||||
reply.raw.write(`data: ${JSON.stringify(current)}\n\n`)
|
||||
}
|
||||
|
||||
// If already in a terminal state, close
|
||||
if (current && ['active', 'completed', 'cancelled', 'expired'].includes(current.status)) {
|
||||
reply.raw.end()
|
||||
return
|
||||
}
|
||||
|
||||
// Subscribe to status updates
|
||||
const unsubscribe = subscribe(`session:${sessionId}:status`, (data) => {
|
||||
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`)
|
||||
if (['paired', 'expired', 'session_ended'].includes(data.type)) {
|
||||
reply.raw.end()
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up on client disconnect
|
||||
request.raw.on('close', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
|
||||
return reply.send({ success: true, data: session })
|
||||
})
|
||||
|
||||
app.get('/session/active', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const session = await getActiveSessionByCustomer(request.customer.id)
|
||||
return reply.send({ success: true, data: session ?? null })
|
||||
})
|
||||
|
||||
app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const session = await endSession(request.params.sessionId, 'customer')
|
||||
return reply.send({ success: true, data: session })
|
||||
})
|
||||
}
|
||||
69
backend/src/routes/public/mitra.chat.routes.js
Normal file
69
backend/src/routes/public/mitra.chat.routes.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
||||
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js'
|
||||
import { getActiveSessionsByMitra, endSession } from '../../services/session.service.js'
|
||||
import { subscribe } from '../../plugins/valkey.js'
|
||||
|
||||
const resolveMitra = async (request, reply) => {
|
||||
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
|
||||
if (!mitra) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Mitra account not found' },
|
||||
})
|
||||
}
|
||||
if (!mitra.is_active) {
|
||||
return reply.code(403).send({
|
||||
success: false,
|
||||
error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive' },
|
||||
})
|
||||
}
|
||||
request.mitra = mitra
|
||||
}
|
||||
|
||||
export const mitraChatRoutes = async (app) => {
|
||||
app.get('/incoming', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
const mitraId = request.mitra.id
|
||||
|
||||
// SSE stream for incoming chat requests
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
// Keep-alive ping
|
||||
const pingInterval = setInterval(() => {
|
||||
reply.raw.write(': ping\n\n')
|
||||
}, 15_000)
|
||||
|
||||
const unsubscribe = subscribe(`mitra:${mitraId}:requests`, (data) => {
|
||||
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`)
|
||||
})
|
||||
|
||||
request.raw.on('close', () => {
|
||||
clearInterval(pingInterval)
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id)
|
||||
return reply.send({ success: true, data: session })
|
||||
})
|
||||
|
||||
app.post('/:sessionId/decline', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
await declinePairingRequest(request.params.sessionId, request.mitra.id)
|
||||
return reply.send({ success: true })
|
||||
})
|
||||
|
||||
app.get('/sessions/active', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
const sessions = await getActiveSessionsByMitra(request.mitra.id)
|
||||
return reply.send({ success: true, data: sessions })
|
||||
})
|
||||
|
||||
app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
const session = await endSession(request.params.sessionId, 'mitra')
|
||||
return reply.send({ success: true, data: session })
|
||||
})
|
||||
}
|
||||
43
backend/src/routes/public/mitra.status.routes.js
Normal file
43
backend/src/routes/public/mitra.status.routes.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
||||
import * as mitraStatusService from '../../services/mitra-status.service.js'
|
||||
|
||||
export const mitraStatusRoutes = async (app) => {
|
||||
// Resolve mitra from Firebase token
|
||||
const resolveMitra = async (request, reply) => {
|
||||
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
|
||||
if (!mitra) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Mitra account not found' },
|
||||
})
|
||||
}
|
||||
if (!mitra.is_active) {
|
||||
return reply.code(403).send({
|
||||
success: false,
|
||||
error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive' },
|
||||
})
|
||||
}
|
||||
request.mitra = mitra
|
||||
}
|
||||
|
||||
app.post('/online', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
await mitraStatusService.setOnline(request.mitra.id)
|
||||
return reply.send({ success: true, data: { is_online: true } })
|
||||
})
|
||||
|
||||
app.post('/offline', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
await mitraStatusService.setOffline(request.mitra.id)
|
||||
return reply.send({ success: true, data: { is_online: false } })
|
||||
})
|
||||
|
||||
app.post('/heartbeat', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
await mitraStatusService.heartbeat(request.mitra.id)
|
||||
return reply.send({ success: true })
|
||||
})
|
||||
|
||||
app.get('/', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
const status = await mitraStatusService.getStatus(request.mitra.id)
|
||||
return reply.send({ success: true, data: status })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user