Files
halobestie-clone/backend/src/services/session-timer.service.js
ramadhan sjamsani ed765d230c Phase 3.1 WS2: Backend FCM fallback, ping config, unread API
- 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>
2026-04-09 14:22:41 +08:00

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)`)
}
}