Files
halobestie-clone/backend/src/services/session-timer.service.js
ramadhan sjamsani f8380163bc Phase 3: session-end UX overhaul + closing-grace cleanup
Promotes the customer-side chat WebSocket to active-session-scoped (driven
by a new `activeSessionProvider`) so home reflects session state in real
time without a per-screen connection. Backend now auto-completes sessions
left in `closing` after a 5-minute grace window so abandoned goodbye flows
don't leave the customer's home permanently locked.

Customer:
- New `activeSessionProvider` (replaces `unread_notifier`) — single source
  of truth for the active session + unread count; polled every 15s.
- Chat WS lifecycle moved to `main.dart` listener on activeSessionProvider.
  Chat screen joins via `connectIfNotConnected`; the new
  `refreshSessionStatus` reconciles flags from the server when re-entering
  an already-connected session (covers missed `sessionClosing`/`sessionExpired`
  WS events).
- Home filters `closing` from the "Sesi Aktif" CTA so a session pending
  goodbye doesn't block "Mulai Curhat".
- Timer-expired UX is a non-dismissible modal (Tutup / Perpanjang) instead
  of an inline bar.
- Early-end goodbye composer gets an amber "Sesi telah ditutup oleh Bestie"
  banner. Goodbye TextEditingController lifted to state so focus changes
  no longer wipe the message.
- Closure provider reset on chat_screen mount to avoid stale
  `ClosureCompleteData` from a previous session leaking into a new view.
- Chat history now lists `closing` sessions with a "Belum ditutup" badge
  that routes to the live chat (goodbye composer) instead of the transcript.

Mitra:
- Same goodbye-controller fix as customer.
- Same chat-history badge + routing for `closing` items.

Backend:
- New `EndedBy.SYSTEM_AUTO_CLOSE` constant.
- `startClosureGraceTimer` extracted in `session-timer.service.js`; wired
  in from `closure.initiateEarlyEnd`, `extension.rejectExtension`, and
  `extension.handleExtensionTimeout`. Cancelled when customer submits
  goodbye.
- Restart recovery (`restoreActiveTimers`) re-arms grace timers and stamps
  any orphaned `closing` rows with `system_auto_close`.
- `getCustomerHistory` / `getMitraHistory` include `closing` alongside
  `completed`; ordering uses `COALESCE(ended_at, created_at)`.

Removed: dead `session_active_screen.dart` (no router entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:47:24 +08:00

205 lines
7.2 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, EndedBy } 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
startClosureGraceTimer(sessionId)
}
// Schedule auto-completion if session stays in `closing` past the grace window.
// Idempotent: clears any existing grace timer before scheduling a new one.
export const startClosureGraceTimer = (sessionId) => {
clearClosureGraceTimer(sessionId)
const graceTimerId = setTimeout(() => autoCompleteIfStillClosing(sessionId), CLOSURE_GRACE_PERIOD_MS)
closureGraceTimers.set(sessionId, graceTimerId)
}
const autoCompleteIfStillClosing = async (sessionId) => {
closureGraceTimers.delete(sessionId)
try {
const [stale] = await sql`
SELECT id FROM chat_sessions
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
`
if (!stale) return // customer submitted goodbye, or another path already finalized
const [updated] = await sql`
UPDATE chat_sessions
SET status = ${SessionStatus.COMPLETED},
ended_at = COALESCE(ended_at, NOW()),
ended_by = ${EndedBy.SYSTEM_AUTO_CLOSE}
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
RETURNING id
`
if (!updated) return
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
sendToSessionParticipant(sessionId, UserType.MITRA, data)
await publish(`session:${sessionId}:status`, data)
console.log(`Auto-completed abandoned session ${sessionId} after grace period`)
} catch (err) {
console.error(`Closure grace auto-complete failed for ${sessionId}:`, err)
}
}
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)`)
}
// Recover pending closure timers — any session still in `closing` after restart
// has lost its in-memory grace setTimeout. We can't know how long it has been
// closing for, so finalize them all to system_auto_close.
const staleClosing = await sql`
UPDATE chat_sessions
SET status = ${SessionStatus.COMPLETED},
ended_at = COALESCE(ended_at, NOW()),
ended_by = ${EndedBy.SYSTEM_AUTO_CLOSE}
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)`)
}
}