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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user