Phase 3 scaffold: chat engine (WebSocket, FCM, pricing, timer, extension, history)

- Backend: WebSocket plugin, chat/pricing/timer/extension/closure/notification services
- Client app: ChatBloc, pricing dialog, chat screen with message status, extension/goodbye flow, history
- Mitra app: MitraChatBloc, ExtensionBloc, chat screen, extension accept/reject, history
- Control center: free trial, extension timeout, early end config toggles
- DB migration: chat_messages, session_closures, session_extensions, customer_transactions tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:58:11 +08:00
parent 844d7234e6
commit b4efcf14c2
47 changed files with 4361 additions and 44 deletions

View File

@@ -0,0 +1,100 @@
import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
import { sendToSessionParticipant } from '../plugins/websocket.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: 'session_timer', remaining_seconds: 60, session_id: sessionId }
sendToSessionParticipant(sessionId, 'customer', data)
sendToSessionParticipant(sessionId, 'mitra', data)
}
const onSessionExpired = async (sessionId) => {
clearSessionTimer(sessionId)
// Check session is still active
const [session] = await sql`
SELECT id, status FROM chat_sessions WHERE id = ${sessionId} AND status = 'active'
`
if (!session) return
// Notify both parties
const data = { type: 'session_expired', session_id: sessionId }
sendToSessionParticipant(sessionId, 'customer', data)
sendToSessionParticipant(sessionId, 'mitra', data)
// Also publish via Valkey for any listeners
await publish(`session:${sessionId}:status`, data)
}
// Restore timers for active sessions on server restart
export const restoreActiveTimers = async () => {
const activeSessions = await sql`
SELECT id, expires_at FROM chat_sessions
WHERE status = '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)`)
}
}