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>
205 lines
7.2 KiB
JavaScript
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)`)
|
|
}
|
|
}
|