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:
2026-04-25 20:47:24 +08:00
parent b59c66f7df
commit f8380163bc
22 changed files with 540 additions and 327 deletions

View File

@@ -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',
})

View File

@@ -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 }

View File

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

View File

@@ -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
`

View File

@@ -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 }
}