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,14 +1,51 @@
import { getDb } from '../db/client.js'
import { getMaxCustomersPerMitra } from './config.service.js'
import { publish } from '../plugins/valkey.js'
import { sendToUser } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js'
import { startSessionTimer } from './session-timer.service.js'
import { startSessionListener } from './chat-handler.service.js'
import { UserType, SessionStatus, NotificationResponse, TransactionType, WsMessage } from '../constants.js'
const sql = getDb()
// Timeout map for active pairing requests (sessionId → timeoutId)
const pairingTimeouts = new Map()
// Send notification to mitra via WebSocket, fall back to FCM if offline
const notifyMitra = async (mitraId, data) => {
const sent = sendToUser(UserType.MITRA, mitraId, data)
if (!sent) {
// Mitra not connected via WebSocket — send FCM push
if (data.type === WsMessage.CHAT_REQUEST) {
await sendPushNotification(UserType.MITRA, mitraId, {
title: 'Permintaan Chat Baru',
body: 'Ada pelanggan yang ingin curhat!',
data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id },
})
}
}
}
// Send notification to customer via WebSocket, fall back to FCM if offline
const notifyCustomer = async (customerId, data) => {
const sent = sendToUser(UserType.CUSTOMER, customerId, data)
if (!sent) {
if (data.type === WsMessage.PAIRED) {
await sendPushNotification(UserType.CUSTOMER, customerId, {
title: 'Bestie Ditemukan!',
body: `${data.mitra_display_name} siap menemanimu curhat`,
data: { type: WsMessage.PAIRED, session_id: data.session_id },
})
} else if (data.type === WsMessage.SESSION_EXPIRED) {
await sendPushNotification(UserType.CUSTOMER, customerId, {
title: 'Tidak Ada Bestie',
body: 'Maaf, tidak ada bestie yang tersedia saat ini.',
data: { type: WsMessage.SESSION_EXPIRED, session_id: data.session_id },
})
}
}
}
export const findAvailableMitras = async () => {
const { max_customers_per_mitra } = await getMaxCustomersPerMitra()
const mitras = await sql`
@@ -19,7 +56,7 @@ export const findAvailableMitras = async () => {
AND s.is_online = true
AND (
SELECT COUNT(*) FROM chat_sessions cs
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')
WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})
) < ${max_customers_per_mitra}
`
return mitras
@@ -30,7 +67,7 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
const [existing] = await sql`
SELECT id, status FROM chat_sessions
WHERE customer_id = ${customerId}
AND status IN ('searching', 'pending_acceptance', 'pending_payment', 'active')
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.ACTIVE})
`
if (existing) {
throw Object.assign(new Error('Customer already has an active session or request'), {
@@ -48,7 +85,7 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
// Create session with duration/price
const [session] = await sql`
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial)
VALUES (${customerId}, 'pending_acceptance', ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false})
VALUES (${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false})
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, created_at
`
@@ -58,9 +95,9 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
INSERT INTO chat_request_notifications (session_id, mitra_id)
VALUES (${session.id}, ${mitra.id})
`
// Publish to mitra's channel
await publish(`mitra:${mitra.id}:requests`, {
type: 'chat_request',
// Notify mitra via WebSocket (FCM fallback if offline)
await notifyMitra(mitra.id, {
type: WsMessage.CHAT_REQUEST,
session_id: session.id,
created_at: session.created_at,
})
@@ -81,8 +118,8 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
// Use a transaction-like approach: update only if status is still pending_acceptance
const [session] = await sql`
UPDATE chat_sessions
SET mitra_id = ${mitraId}, status = 'pending_payment', paired_at = NOW()
WHERE id = ${sessionId} AND status = 'pending_acceptance' AND mitra_id IS NULL
SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW()
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL
RETURNING id, customer_id, mitra_id, status, paired_at
`
@@ -95,14 +132,14 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
// Mark this mitra's notification as accepted
await sql`
UPDATE chat_request_notifications
SET response = 'accepted', responded_at = NOW()
SET response = ${NotificationResponse.ACCEPTED}, responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId}
`
// Mark other mitras' notifications as ignored
await sql`
UPDATE chat_request_notifications
SET response = 'ignored', responded_at = NOW()
SET response = ${NotificationResponse.IGNORED}, responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL
`
@@ -116,7 +153,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
// Auto-skip payment for now: move to active and set expires_at
const [activeSession] = await sql`
UPDATE chat_sessions
SET status = 'active',
SET status = ${SessionStatus.ACTIVE},
expires_at = CASE
WHEN duration_minutes IS NOT NULL THEN NOW() + (duration_minutes || ' minutes')::interval
ELSE NULL
@@ -127,7 +164,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
// Record transaction
if (activeSession.duration_minutes) {
const txType = activeSession.is_free_trial ? 'free_trial' : 'paid'
const txType = activeSession.is_free_trial ? TransactionType.FREE_TRIAL : TransactionType.PAID
await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
@@ -147,12 +184,12 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
SELECT display_name FROM mitras WHERE id = ${mitraId}
`
// Notify customer
await publish(`session:${sessionId}:status`, {
type: 'paired',
// Notify customer via WebSocket (FCM fallback)
await notifyCustomer(activeSession.customer_id, {
type: WsMessage.PAIRED,
session_id: sessionId,
mitra_display_name: mitra.display_name,
status: 'active',
status: SessionStatus.ACTIVE,
})
// Notify other mitras to dismiss the request
@@ -161,8 +198,8 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId}
`
for (const n of notifications) {
await publish(`mitra:${n.mitra_id}:requests`, {
type: 'chat_request_closed',
await notifyMitra(n.mitra_id, {
type: WsMessage.CHAT_REQUEST_CLOSED,
session_id: sessionId,
})
}
@@ -173,7 +210,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
export const declinePairingRequest = async (sessionId, mitraId) => {
await sql`
UPDATE chat_request_notifications
SET response = 'declined', responded_at = NOW()
SET response = ${NotificationResponse.DECLINED}, responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL
`
}
@@ -181,9 +218,9 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
export const cancelPairingRequest = async (sessionId, customerId) => {
const [session] = await sql`
UPDATE chat_sessions
SET status = 'cancelled'
SET status = ${SessionStatus.CANCELLED}
WHERE id = ${sessionId} AND customer_id = ${customerId}
AND status IN ('searching', 'pending_acceptance')
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
RETURNING id, status
`
@@ -203,7 +240,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
// Mark all notifications as ignored
await sql`
UPDATE chat_request_notifications
SET response = 'ignored', responded_at = NOW()
SET response = ${NotificationResponse.IGNORED}, responded_at = NOW()
WHERE session_id = ${sessionId} AND response IS NULL
`
@@ -212,8 +249,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
`
for (const n of notifications) {
await publish(`mitra:${n.mitra_id}:requests`, {
type: 'chat_request_closed',
await notifyMitra(n.mitra_id, {
type: WsMessage.CHAT_REQUEST_CLOSED,
session_id: sessionId,
})
}
@@ -224,8 +261,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
export const expirePairingRequest = async (sessionId) => {
const [session] = await sql`
UPDATE chat_sessions
SET status = 'expired'
WHERE id = ${sessionId} AND status = 'pending_acceptance'
SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, status
`
if (!session) return null
@@ -235,13 +272,13 @@ export const expirePairingRequest = async (sessionId) => {
// Mark all pending notifications as ignored
await sql`
UPDATE chat_request_notifications
SET response = 'ignored', responded_at = NOW()
SET response = ${NotificationResponse.IGNORED}, responded_at = NOW()
WHERE session_id = ${sessionId} AND response IS NULL
`
// Notify customer
await publish(`session:${sessionId}:status`, {
type: 'expired',
// Notify customer via WebSocket (FCM fallback)
await notifyCustomer(session.customer_id, {
type: WsMessage.SESSION_EXPIRED,
session_id: sessionId,
})
@@ -250,8 +287,8 @@ export const expirePairingRequest = async (sessionId) => {
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
`
for (const n of notifications) {
await publish(`mitra:${n.mitra_id}:requests`, {
type: 'chat_request_closed',
await notifyMitra(n.mitra_id, {
type: WsMessage.CHAT_REQUEST_CLOSED,
session_id: sessionId,
})
}