- Add require_mitra_ping + mitra_ping_interval_seconds config keys (migration) - Add getMitraPingConfig/setMitraPingConfig to config service - Add GET/PATCH /internal/config/mitra-ping routes for control center - Update mitra status service: honor ping config in auto-offline sweep, include ping config in GET /api/mitra/status response - Enhance pairing FCM payload with action: 'open_accept' for deep-link - Add FCM fallback to closure.service (initiateEarlyEnd, completeSession) - Add FCM fallback to session-timer.service (onSessionExpired) - Add unread count queries (getActiveSessionByCustomerWithUnread, getActiveSessionsByMitraWithUnread) - Add GET /api/client/chat/session/active-with-unread route - Add GET /api/mitra/chat-requests/sessions/active-with-unread route Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
184 lines
6.3 KiB
JavaScript
184 lines
6.3 KiB
JavaScript
import { getDb } from '../db/client.js'
|
|
import { publish } from '../plugins/valkey.js'
|
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
|
import { sendPushNotification } from './notification.service.js'
|
|
import { UserType, SessionStatus, WsMessage } from '../constants.js'
|
|
|
|
const sql = getDb()
|
|
|
|
// Active session timers: sessionId → { warningTimeout, expiryTimeout }
|
|
const sessionTimers = new Map()
|
|
|
|
export const startSessionTimer = (sessionId, expiresAt) => {
|
|
const now = Date.now()
|
|
const expiresMs = new Date(expiresAt).getTime()
|
|
const warningMs = expiresMs - 60_000 // 1 minute before expiry
|
|
|
|
// Clear any existing timers
|
|
clearSessionTimer(sessionId)
|
|
|
|
const timers = {}
|
|
|
|
// Warning timer (1 min before expiry)
|
|
if (warningMs > now) {
|
|
timers.warningTimeout = setTimeout(() => {
|
|
onSessionWarning(sessionId)
|
|
}, warningMs - now)
|
|
}
|
|
|
|
// Expiry timer
|
|
if (expiresMs > now) {
|
|
timers.expiryTimeout = setTimeout(() => {
|
|
onSessionExpired(sessionId)
|
|
}, expiresMs - now)
|
|
} else {
|
|
// Already expired
|
|
onSessionExpired(sessionId)
|
|
return
|
|
}
|
|
|
|
sessionTimers.set(sessionId, timers)
|
|
}
|
|
|
|
export const clearSessionTimer = (sessionId) => {
|
|
const timers = sessionTimers.get(sessionId)
|
|
if (timers) {
|
|
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
|
|
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
|
|
sessionTimers.delete(sessionId)
|
|
}
|
|
}
|
|
|
|
export const extendSessionTimer = async (sessionId, additionalMinutes) => {
|
|
const [session] = await sql`
|
|
UPDATE chat_sessions
|
|
SET expires_at = expires_at + ${additionalMinutes + ' minutes'}::interval,
|
|
extended_minutes = extended_minutes + ${additionalMinutes}
|
|
WHERE id = ${sessionId}
|
|
RETURNING id, expires_at
|
|
`
|
|
if (session) {
|
|
startSessionTimer(sessionId, session.expires_at)
|
|
}
|
|
return session
|
|
}
|
|
|
|
const onSessionWarning = (sessionId) => {
|
|
const data = { type: WsMessage.SESSION_TIMER, remaining_seconds: 60, session_id: sessionId }
|
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
|
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
|
}
|
|
|
|
// Grace period timers for auto-completing abandoned sessions
|
|
const closureGraceTimers = new Map()
|
|
|
|
const CLOSURE_GRACE_PERIOD_MS = 5 * 60_000 // 5 minutes
|
|
|
|
const onSessionExpired = async (sessionId) => {
|
|
clearSessionTimer(sessionId)
|
|
|
|
// Move session to closing status
|
|
const [session] = await sql`
|
|
UPDATE chat_sessions
|
|
SET status = ${SessionStatus.CLOSING}
|
|
WHERE id = ${sessionId} AND status = ${SessionStatus.ACTIVE}
|
|
RETURNING id, customer_id, mitra_id
|
|
`
|
|
if (!session) return
|
|
|
|
// Notify customer — sees extend/close dialog; FCM fallback if WebSocket is down
|
|
const expiredData = { type: WsMessage.SESSION_EXPIRED, session_id: sessionId }
|
|
const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, expiredData)
|
|
if (!customerSent) {
|
|
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
|
|
title: 'Waktu Sesi Habis',
|
|
body: 'Sesi curhat kamu telah habis. Ketuk untuk memperpanjang atau mengakhiri.',
|
|
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
|
|
})
|
|
}
|
|
|
|
// Notify mitra — sees expired + closing (waits for customer's decision or goodbye)
|
|
const mitraSent = sendToSessionParticipant(sessionId, UserType.MITRA, expiredData)
|
|
sendToSessionParticipant(sessionId, UserType.MITRA, {
|
|
type: WsMessage.SESSION_CLOSING, session_id: sessionId,
|
|
})
|
|
if (!mitraSent) {
|
|
await sendPushNotification(UserType.MITRA, session.mitra_id, {
|
|
title: 'Sesi Berakhir',
|
|
body: 'Sesi curhat telah berakhir. Ketuk untuk menulis pesan penutup.',
|
|
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
|
|
})
|
|
}
|
|
|
|
// Also publish via Valkey for any listeners
|
|
await publish(`session:${sessionId}:status`, expiredData)
|
|
|
|
// Start grace period — auto-complete if closing messages aren't submitted
|
|
const graceTimerId = setTimeout(async () => {
|
|
closureGraceTimers.delete(sessionId)
|
|
try {
|
|
const [stale] = await sql`
|
|
SELECT id FROM chat_sessions
|
|
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
|
|
`
|
|
if (stale) {
|
|
await sql`
|
|
UPDATE chat_sessions
|
|
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = 'system'
|
|
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
|
|
`
|
|
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
|
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
|
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
|
console.log(`Auto-completed abandoned session ${sessionId} after grace period`)
|
|
}
|
|
} catch (_) {}
|
|
}, CLOSURE_GRACE_PERIOD_MS)
|
|
closureGraceTimers.set(sessionId, graceTimerId)
|
|
}
|
|
|
|
export const clearClosureGraceTimer = (sessionId) => {
|
|
const timerId = closureGraceTimers.get(sessionId)
|
|
if (timerId) {
|
|
clearTimeout(timerId)
|
|
closureGraceTimers.delete(sessionId)
|
|
}
|
|
}
|
|
|
|
// Restore timers for active sessions on server restart
|
|
export const restoreActiveTimers = async () => {
|
|
// Expire sessions that already passed their expires_at while server was down
|
|
const staleSessions = await sql`
|
|
UPDATE chat_sessions
|
|
SET status = ${SessionStatus.COMPLETED}, ended_at = expires_at, ended_by = 'system'
|
|
WHERE status = ${SessionStatus.ACTIVE} AND expires_at IS NOT NULL AND expires_at <= NOW()
|
|
RETURNING id
|
|
`
|
|
if (staleSessions.length > 0) {
|
|
console.log(`Auto-completed ${staleSessions.length} expired session(s)`)
|
|
}
|
|
|
|
// Auto-complete sessions stuck in 'closing' status (abandoned during grace period)
|
|
const staleClosing = await sql`
|
|
UPDATE chat_sessions
|
|
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = 'system'
|
|
WHERE status = ${SessionStatus.CLOSING}
|
|
RETURNING id
|
|
`
|
|
if (staleClosing.length > 0) {
|
|
console.log(`Auto-completed ${staleClosing.length} abandoned closing session(s)`)
|
|
}
|
|
|
|
// Restore timers for sessions still within their time window
|
|
const activeSessions = await sql`
|
|
SELECT id, expires_at FROM chat_sessions
|
|
WHERE status = ${SessionStatus.ACTIVE} AND expires_at IS NOT NULL AND expires_at > NOW()
|
|
`
|
|
for (const session of activeSessions) {
|
|
startSessionTimer(session.id, session.expires_at)
|
|
}
|
|
if (activeSessions.length > 0) {
|
|
console.log(`Restored ${activeSessions.length} session timer(s)`)
|
|
}
|
|
}
|