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