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>
This commit is contained in:
@@ -56,6 +56,7 @@ export const TransactionType = Object.freeze({
|
||||
// Who ended a session
|
||||
export const EndedBy = Object.freeze({
|
||||
SYSTEM: 'system',
|
||||
SYSTEM_AUTO_CLOSE: 'system_auto_close',
|
||||
CUSTOMER: 'customer',
|
||||
MITRA: 'mitra',
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { publish } from '../plugins/valkey.js'
|
||||
import { clearSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
|
||||
import { clearSessionTimer, clearClosureGraceTimer, startClosureGraceTimer } from './session-timer.service.js'
|
||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||
import { sendPushNotification } from './notification.service.js'
|
||||
import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js'
|
||||
@@ -27,6 +27,12 @@ export const submitClosureMessage = async (sessionId, userType, userId, message)
|
||||
RETURNING id, session_id, user_type, message, created_at
|
||||
`
|
||||
|
||||
// Customer submitted their goodbye — cancel the auto-close grace timer.
|
||||
// (Even if mitra hasn't submitted yet, the session is no longer "stuck".)
|
||||
if (userType === UserType.CUSTOMER) {
|
||||
clearClosureGraceTimer(sessionId)
|
||||
}
|
||||
|
||||
// Check if both parties have submitted
|
||||
const closures = await sql`
|
||||
SELECT user_type FROM session_closures WHERE session_id = ${sessionId}
|
||||
@@ -105,6 +111,7 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
|
||||
}
|
||||
|
||||
clearSessionTimer(sessionId)
|
||||
startClosureGraceTimer(sessionId)
|
||||
|
||||
// Notify both parties to enter closure flow, FCM fallback if WebSocket is down
|
||||
const data = { type: WsMessage.SESSION_CLOSING, session_id: sessionId, ended_by: userType }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { publish } from '../plugins/valkey.js'
|
||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||
import { extendSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
|
||||
import { extendSessionTimer, clearClosureGraceTimer, startClosureGraceTimer } from './session-timer.service.js'
|
||||
import { UserType, SessionStatus, ExtensionStatus, TransactionType, WsMessage } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
@@ -142,6 +142,7 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept
|
||||
type: WsMessage.SESSION_CLOSING,
|
||||
session_id: sessionId,
|
||||
})
|
||||
startClosureGraceTimer(sessionId)
|
||||
}
|
||||
|
||||
return extension
|
||||
@@ -174,4 +175,5 @@ const timeoutExtension = async (extensionId, sessionId) => {
|
||||
type: WsMessage.SESSION_CLOSING,
|
||||
session_id: sessionId,
|
||||
})
|
||||
startClosureGraceTimer(sessionId)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
import { UserType, SessionStatus, WsMessage, EndedBy } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
@@ -114,29 +114,46 @@ const onSessionExpired = async (sessionId) => {
|
||||
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)
|
||||
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) {
|
||||
@@ -158,10 +175,14 @@ export const restoreActiveTimers = async () => {
|
||||
console.log(`Auto-completed ${staleSessions.length} expired session(s)`)
|
||||
}
|
||||
|
||||
// Auto-complete sessions stuck in 'closing' status (abandoned during grace period)
|
||||
// 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 = NOW(), ended_by = 'system'
|
||||
SET status = ${SessionStatus.COMPLETED},
|
||||
ended_at = COALESCE(ended_at, NOW()),
|
||||
ended_by = ${EndedBy.SYSTEM_AUTO_CLOSE}
|
||||
WHERE status = ${SessionStatus.CLOSING}
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
@@ -210,12 +210,13 @@ export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } =
|
||||
FROM chat_sessions cs
|
||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||
WHERE cs.customer_id = ${customerId}
|
||||
AND cs.status = ${SessionStatus.COMPLETED}
|
||||
ORDER BY cs.ended_at DESC
|
||||
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`
|
||||
const [{ count }] = await sql`
|
||||
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = ${SessionStatus.COMPLETED}
|
||||
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId}
|
||||
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||
`
|
||||
return { items, total: Number(count), page, limit }
|
||||
}
|
||||
@@ -231,12 +232,13 @@ export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) =>
|
||||
FROM chat_sessions cs
|
||||
INNER JOIN customers c ON c.id = cs.customer_id
|
||||
WHERE cs.mitra_id = ${mitraId}
|
||||
AND cs.status = ${SessionStatus.COMPLETED}
|
||||
ORDER BY cs.ended_at DESC
|
||||
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`
|
||||
const [{ count }] = await sql`
|
||||
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = ${SessionStatus.COMPLETED}
|
||||
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId}
|
||||
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||
`
|
||||
return { items, total: Number(count), page, limit }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user