Phase 3 testing fixes: Fastify 5, SSE→WebSocket+FCM, enums, security, session lifecycle

- Upgrade Fastify 4→5 with all plugins (@fastify/websocket 11, cors 11, sensible 6)
- Migrate all SSE endpoints to WebSocket + FCM push (mitra chat requests, customer pairing status)
- Add flutter_local_notifications for foreground push notifications with sound
- Add splash screen to both apps (hide auth loading flash)
- Introduce constants/enums across entire codebase (no raw string literals)
- Move price tiers from hardcoded array to app_config DB (data-driven, includes 1-min test tier)
- Add session ownership validation on all shared chat routes
- Add ownership checks on endSession, respondToExtension, requestExtension
- Fix session timer: auto-complete expired/stale sessions on server restart
- Add 5-min grace period for abandoned closing sessions
- Fix extension flow: proper session_resumed handling, clearExtensionRequest, closure grace timer cleanup
- Fix chat screens: ConnectChat in initState, session status check on connect
- Fix customer expired view: 5-min countdown, closure state priority over expired state
- Fix mitra extension UI: loading spinner, disable buttons, handle EXTENSION_RESOLVED error
- Fix GoRouter navigation consistency (no more Navigator.pushNamed)
- Fix goodbye view keyboard overflow (SingleChildScrollView)
- Add active session card on customer home screen with refresh on navigate back
- Fix PricingBottomSheet extension mode (RequestExtension instead of new pairing)
- Send session_resumed to both parties on extension accept

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 00:17:25 +08:00
parent b4efcf14c2
commit b0502ac92b
58 changed files with 2148 additions and 709 deletions

View File

@@ -1,7 +1,8 @@
import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
import { clearSessionTimer } from './session-timer.service.js'
import { clearSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
import { sendToSessionParticipant } from '../plugins/websocket.js'
import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js'
const sql = getDb()
@@ -9,7 +10,7 @@ export const submitClosureMessage = async (sessionId, userType, userId, message)
// Verify session is in closing or active state (for early end)
const [session] = await sql`
SELECT id, status FROM chat_sessions
WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending')
WHERE id = ${sessionId} AND status IN (${SessionStatus.CLOSING}, ${SessionStatus.ACTIVE}, ${SessionStatus.EXTENDING})
`
if (!session) {
throw Object.assign(new Error('Session not found or already completed'), {
@@ -29,8 +30,8 @@ export const submitClosureMessage = async (sessionId, userType, userId, message)
const closures = await sql`
SELECT user_type FROM session_closures WHERE session_id = ${sessionId}
`
const hasCustomer = closures.some((c) => c.user_type === 'customer')
const hasMitra = closures.some((c) => c.user_type === 'mitra')
const hasCustomer = closures.some((c) => c.user_type === UserType.CUSTOMER)
const hasMitra = closures.some((c) => c.user_type === UserType.MITRA)
if (hasCustomer && hasMitra) {
// Both submitted — complete the session
@@ -42,28 +43,29 @@ export const submitClosureMessage = async (sessionId, userType, userId, message)
export const completeSession = async (sessionId) => {
clearSessionTimer(sessionId)
clearClosureGraceTimer(sessionId)
const [session] = await sql`
UPDATE chat_sessions
SET status = 'completed', ended_at = NOW(), ended_by = 'system'
WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending')
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = ${EndedBy.SYSTEM}
WHERE id = ${sessionId} AND status IN (${SessionStatus.CLOSING}, ${SessionStatus.ACTIVE}, ${SessionStatus.EXTENDING})
RETURNING id, customer_id, mitra_id, status, ended_at
`
if (!session) return null
// Notify both parties
const data = { type: 'session_completed', session_id: sessionId }
sendToSessionParticipant(sessionId, 'customer', data)
sendToSessionParticipant(sessionId, 'mitra', data)
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
sendToSessionParticipant(sessionId, UserType.MITRA, data)
await publish(`session:${sessionId}:status`, { type: 'session_ended', session_id: sessionId })
await publish(`session:${sessionId}:status`, { type: WsMessage.SESSION_ENDED, session_id: sessionId })
return session
}
export const initiateEarlyEnd = async (sessionId, userType) => {
// Check if early end is enabled for this user type
const configKey = userType === 'mitra' ? 'early_end_mitra_enabled' : 'early_end_customer_enabled'
const configKey = userType === UserType.MITRA ? 'early_end_mitra_enabled' : 'early_end_customer_enabled'
const [configRow] = await sql`SELECT value FROM app_config WHERE key = ${configKey}`
const enabled = configRow?.value?.value ?? false
@@ -76,8 +78,8 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
// Move session to closing
const [session] = await sql`
UPDATE chat_sessions
SET status = 'closing', ended_by = ${userType}
WHERE id = ${sessionId} AND status = 'active'
SET status = ${SessionStatus.CLOSING}, ended_by = ${userType}
WHERE id = ${sessionId} AND status = ${SessionStatus.ACTIVE}
RETURNING id, customer_id, mitra_id
`
if (!session) {
@@ -89,9 +91,9 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
clearSessionTimer(sessionId)
// Notify both parties to enter closure flow
const data = { type: 'session_closing', session_id: sessionId, ended_by: userType }
sendToSessionParticipant(sessionId, 'customer', data)
sendToSessionParticipant(sessionId, 'mitra', data)
const data = { type: WsMessage.SESSION_CLOSING, session_id: sessionId, ended_by: userType }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
sendToSessionParticipant(sessionId, UserType.MITRA, data)
return session
}