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 { sendToSessionParticipant } from '../plugins/websocket.js'
import { extendSessionTimer } from './session-timer.service.js'
import { extendSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
import { UserType, SessionStatus, ExtensionStatus, TransactionType, WsMessage } from '../constants.js'
const sql = getDb()
@@ -14,28 +15,29 @@ const getExtensionTimeout = async () => {
}
export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => {
// Verify session belongs to customer and just expired
// Verify session belongs to customer and is in an extendable state
const [session] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
WHERE id = ${sessionId} AND customer_id = ${customerId}
AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.CLOSING})
`
if (!session) {
throw Object.assign(new Error('Session not found'), { code: 'NOT_FOUND', statusCode: 404 })
throw Object.assign(new Error('Session not found or already ended'), { code: 'SESSION_NOT_ACTIVE', statusCode: 409 })
}
// Create extension record
const [extension] = await sql`
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status)
VALUES (${sessionId}, ${duration_minutes}, ${price}, 'pending')
VALUES (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING})
RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at
`
// Pause the session
await sql`UPDATE chat_sessions SET status = 'extending' WHERE id = ${sessionId}`
await sql`UPDATE chat_sessions SET status = ${SessionStatus.EXTENDING} WHERE id = ${sessionId}`
// Notify mitra
sendToSessionParticipant(sessionId, 'mitra', {
type: 'extension_request',
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.EXTENSION_REQUEST,
extension_id: extension.id,
session_id: sessionId,
duration_minutes,
@@ -43,8 +45,8 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
})
// Notify customer that chat is paused
sendToSessionParticipant(sessionId, 'customer', {
type: 'session_paused',
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_PAUSED,
session_id: sessionId,
reason: 'extension_pending',
})
@@ -62,12 +64,20 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
}
export const respondToExtension = async (extensionId, sessionId, mitraId, accepted) => {
const status = accepted ? 'accepted' : 'rejected'
// Verify session belongs to this mitra
const [session] = await sql`
SELECT id FROM chat_sessions WHERE id = ${sessionId} AND mitra_id = ${mitraId}
`
if (!session) {
throw Object.assign(new Error('Session not found'), { code: 'FORBIDDEN', statusCode: 403 })
}
const status = accepted ? ExtensionStatus.ACCEPTED : ExtensionStatus.REJECTED
const [extension] = await sql`
UPDATE session_extensions
SET status = ${status}, responded_at = NOW()
WHERE id = ${extensionId} AND status = 'pending'
WHERE id = ${extensionId} AND session_id = ${sessionId} AND status = ${ExtensionStatus.PENDING}
RETURNING id, session_id, requested_duration_minutes, requested_price, status
`
@@ -85,43 +95,50 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept
}
if (accepted) {
// Clear any pending grace timer from the previous expiry
clearClosureGraceTimer(sessionId)
// Extend the session
await extendSessionTimer(extension.session_id, extension.requested_duration_minutes)
// Resume session
await sql`UPDATE chat_sessions SET status = 'active' WHERE id = ${extension.session_id}`
await sql`UPDATE chat_sessions SET status = ${SessionStatus.ACTIVE} WHERE id = ${extension.session_id}`
// Record transaction
await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
SELECT customer_id, id, 'extension', ${extension.requested_price}
SELECT customer_id, id, ${TransactionType.EXTENSION}, ${extension.requested_price}
FROM chat_sessions WHERE id = ${extension.session_id}
`
// Notify both parties
sendToSessionParticipant(sessionId, 'customer', {
type: 'extension_response',
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.EXTENSION_RESPONSE,
accepted: true,
duration_minutes: extension.requested_duration_minutes,
})
sendToSessionParticipant(sessionId, 'mitra', {
type: 'session_resumed',
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_RESUMED,
session_id: sessionId,
})
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.SESSION_RESUMED,
session_id: sessionId,
})
} else {
// Rejected — proceed to closure
await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}`
await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}`
sendToSessionParticipant(sessionId, 'customer', {
type: 'extension_response',
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.EXTENSION_RESPONSE,
accepted: false,
})
sendToSessionParticipant(sessionId, 'mitra', {
type: 'session_closing',
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.SESSION_CLOSING,
session_id: sessionId,
})
sendToSessionParticipant(sessionId, 'customer', {
type: 'session_closing',
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_CLOSING,
session_id: sessionId,
})
}
@@ -134,26 +151,26 @@ const timeoutExtension = async (extensionId, sessionId) => {
const [extension] = await sql`
UPDATE session_extensions
SET status = 'timeout', responded_at = NOW()
WHERE id = ${extensionId} AND status = 'pending'
SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW()
WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING}
RETURNING id, session_id
`
if (!extension) return
// Timeout = proceed to closure
await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}`
await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}`
sendToSessionParticipant(sessionId, 'customer', {
type: 'extension_response',
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.EXTENSION_RESPONSE,
accepted: false,
reason: 'timeout',
})
sendToSessionParticipant(sessionId, 'mitra', {
type: 'session_closing',
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.SESSION_CLOSING,
session_id: sessionId,
})
sendToSessionParticipant(sessionId, 'customer', {
type: 'session_closing',
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_CLOSING,
session_id: sessionId,
})
}