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,5 +1,6 @@
import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
import { UserType, SessionStatus, WsMessage } from '../constants.js'
const sql = getDb()
@@ -11,7 +12,7 @@ export const getActiveSessionByCustomer = async (customerId) => {
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN ('active', 'pending_payment', 'extending', 'closing')
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING})
ORDER BY cs.created_at DESC LIMIT 1
`
return session
@@ -25,17 +26,20 @@ export const getActiveSessionsByMitra = async (mitraId) => {
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
WHERE cs.mitra_id = ${mitraId}
AND cs.status IN ('active', 'pending_payment', 'extending', 'closing')
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING})
ORDER BY cs.created_at DESC
`
return sessions
}
export const endSession = async (sessionId, endedBy) => {
export const endSession = async (sessionId, endedBy, userId) => {
// Validate session belongs to this user
const ownerCol = endedBy === UserType.CUSTOMER ? 'customer_id' : 'mitra_id'
const [session] = await sql`
UPDATE chat_sessions
SET status = 'completed', ended_at = NOW(), ended_by = ${endedBy}
WHERE id = ${sessionId} AND status IN ('active', 'pending_payment')
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = ${endedBy}
WHERE id = ${sessionId} AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})
AND ${sql(ownerCol)} = ${userId}
RETURNING id, customer_id, mitra_id, status, ended_at, ended_by
`
@@ -47,7 +51,7 @@ export const endSession = async (sessionId, endedBy) => {
// Notify both parties
await publish(`session:${sessionId}:status`, {
type: 'session_ended',
type: WsMessage.SESSION_ENDED,
session_id: sessionId,
ended_by: endedBy,
})
@@ -59,7 +63,7 @@ export const rerouteSession = async (sessionId, newMitraId) => {
// Get current session
const [current] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
WHERE id = ${sessionId} AND status IN ('active', 'pending_payment')
WHERE id = ${sessionId} AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})
`
if (!current) {
@@ -94,7 +98,7 @@ export const rerouteSession = async (sessionId, newMitraId) => {
// Notify customer about reroute
await publish(`session:${sessionId}:status`, {
type: 'rerouted',
type: WsMessage.REROUTED,
session_id: sessionId,
mitra_display_name: newMitra.display_name,
})
@@ -102,14 +106,14 @@ export const rerouteSession = async (sessionId, newMitraId) => {
// Notify old mitra session removed
if (oldMitraId) {
await publish(`mitra:${oldMitraId}:requests`, {
type: 'session_rerouted',
type: WsMessage.SESSION_REROUTED,
session_id: sessionId,
})
}
// Notify new mitra about new session
await publish(`mitra:${newMitraId}:requests`, {
type: 'session_assigned',
type: WsMessage.SESSION_ASSIGNED,
session_id: sessionId,
})
@@ -157,17 +161,17 @@ export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } =
SELECT cs.id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
m.display_name AS mitra_display_name,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.customer_id = ${customerId}
AND cs.status = 'completed'
AND cs.status = ${SessionStatus.COMPLETED}
ORDER BY cs.ended_at DESC
LIMIT ${limit} OFFSET ${offset}
`
const [{ count }] = await sql`
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = 'completed'
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = ${SessionStatus.COMPLETED}
`
return { items, total: Number(count), page, limit }
}
@@ -178,17 +182,17 @@ export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) =>
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
c.display_name AS customer_display_name,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
WHERE cs.mitra_id = ${mitraId}
AND cs.status = 'completed'
AND cs.status = ${SessionStatus.COMPLETED}
ORDER BY cs.ended_at DESC
LIMIT ${limit} OFFSET ${offset}
`
const [{ count }] = await sql`
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = 'completed'
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = ${SessionStatus.COMPLETED}
`
return { items, total: Number(count), page, limit }
}