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

101
backend/src/constants.js Normal file
View File

@@ -0,0 +1,101 @@
// User types
export const UserType = Object.freeze({
CUSTOMER: 'customer',
MITRA: 'mitra',
})
// Chat session statuses
export const SessionStatus = Object.freeze({
SEARCHING: 'searching',
PENDING_ACCEPTANCE: 'pending_acceptance',
PENDING_PAYMENT: 'pending_payment',
ACTIVE: 'active',
EXTENDING: 'extending',
CLOSING: 'closing',
COMPLETED: 'completed',
CANCELLED: 'cancelled',
EXPIRED: 'expired',
})
// Chat message statuses
export const MessageStatus = Object.freeze({
SENT: 'sent',
DELIVERED: 'delivered',
READ: 'read',
})
// Chat message types
export const MessageType = Object.freeze({
TEXT: 'text',
})
// Chat request notification responses
export const NotificationResponse = Object.freeze({
ACCEPTED: 'accepted',
DECLINED: 'declined',
IGNORED: 'ignored',
})
// Session extension statuses
export const ExtensionStatus = Object.freeze({
PENDING: 'pending',
ACCEPTED: 'accepted',
REJECTED: 'rejected',
TIMEOUT: 'timeout',
})
// Customer transaction types
export const TransactionType = Object.freeze({
FREE_TRIAL: 'free_trial',
PAID: 'paid',
EXTENSION: 'extension',
})
// Who ended a session
export const EndedBy = Object.freeze({
SYSTEM: 'system',
CUSTOMER: 'customer',
MITRA: 'mitra',
})
// WebSocket message types
export const WsMessage = Object.freeze({
// Auth
AUTH: 'auth',
AUTH_OK: 'auth_ok',
ERROR: 'error',
// Chat
MESSAGE: 'message',
MESSAGE_ACK: 'message_ack',
MESSAGE_STATUS: 'message_status',
TYPING: 'typing',
// Pairing
CHAT_REQUEST: 'chat_request',
CHAT_REQUEST_CLOSED: 'chat_request_closed',
PAIRED: 'paired',
// Session lifecycle
SESSION_TIMER: 'session_timer',
SESSION_EXPIRED: 'session_expired',
SESSION_CLOSING: 'session_closing',
SESSION_COMPLETED: 'session_completed',
SESSION_ENDED: 'session_ended',
SESSION_PAUSED: 'session_paused',
SESSION_RESUMED: 'session_resumed',
SESSION_ASSIGNED: 'session_assigned',
SESSION_REROUTED: 'session_rerouted',
REROUTED: 'rerouted',
// Extension
EXTENSION_REQUEST: 'extension_request',
EXTENSION_RESPONSE: 'extension_response',
// Early end
EARLY_END: 'early_end',
// Delivery
DELIVERED: 'delivered',
READ: 'read',
})

View File

@@ -261,6 +261,19 @@ const migrate = async () => {
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('price_tiers', ${sql.json({ tiers: [
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
{ duration_minutes: 60, price: 150000, label: '60 Menit' },
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
]})})
ON CONFLICT (key) DO NOTHING
`
console.log('Migration complete.')
await sql.end()
}

View File

@@ -3,6 +3,7 @@ import { verifyFirebaseToken } from './firebase.js'
import { getCustomerByFirebaseUid } from '../services/customer.service.js'
import { getMitraByFirebaseUid } from '../services/mitra.service.js'
import { subscribe, publish } from './valkey.js'
import { UserType, WsMessage } from '../constants.js'
// Track active WebSocket connections: sessionId → { customer, mitra }
const sessionConnections = new Map()
@@ -56,24 +57,24 @@ export const registerWebSocketRoute = (app) => {
try {
msg = JSON.parse(raw.toString())
} catch {
send({ type: 'error', message: 'Invalid JSON' })
send({ type: WsMessage.ERROR, message: 'Invalid JSON' })
return
}
// Handle auth message
if (msg.type === 'auth') {
if (msg.type === WsMessage.AUTH) {
try {
const decoded = await verifyFirebaseToken(msg.token)
const customer = await getCustomerByFirebaseUid(decoded.uid)
const mitra = customer ? null : await getMitraByFirebaseUid(decoded.uid)
if (!customer && !mitra) {
send({ type: 'error', message: 'Account not found' })
send({ type: WsMessage.ERROR, message: 'Account not found' })
socket.close()
return
}
const userType = customer ? 'customer' : 'mitra'
const userType = customer ? UserType.CUSTOMER : UserType.MITRA
const userId = customer ? customer.id : mitra.id
const sessionId = msg.session_id
@@ -101,9 +102,9 @@ export const registerWebSocketRoute = (app) => {
valkeyUnsubscribes.push(unsub)
}
send({ type: 'auth_ok', user_type: userType, user_id: userId })
send({ type: WsMessage.AUTH_OK, user_type: userType, user_id: userId })
} catch (err) {
send({ type: 'error', message: 'Authentication failed' })
send({ type: WsMessage.ERROR, message: 'Authentication failed' })
socket.close()
}
return
@@ -111,7 +112,7 @@ export const registerWebSocketRoute = (app) => {
// All other messages require authentication
if (!authenticatedUser) {
send({ type: 'error', message: 'Not authenticated. Send auth message first.' })
send({ type: WsMessage.ERROR, message: 'Not authenticated. Send auth message first.' })
return
}
@@ -135,7 +136,7 @@ export const registerWebSocketRoute = (app) => {
const conns = sessionConnections.get(authenticatedUser.sessionId)
if (conns) {
delete conns[authenticatedUser.type]
if (!conns.customer && !conns.mitra) {
if (!conns[UserType.CUSTOMER] && !conns[UserType.MITRA]) {
sessionConnections.delete(authenticatedUser.sessionId)
}
}

View File

@@ -101,4 +101,31 @@ export const internalConfigRoutes = async (app) => {
const config = await setEarlyEndConfig({ mitra_enabled, customer_enabled })
return reply.send({ success: true, data: config })
})
// --- Price Tiers ---
app.get('/price-tiers', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const { getPriceTiers } = await import('../../services/pricing.service.js')
const tiers = await getPriceTiers()
return reply.send({ success: true, data: tiers })
})
app.patch('/price-tiers', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { tiers } = request.body ?? {}
if (!Array.isArray(tiers) || tiers.length === 0) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'tiers must be a non-empty array' } })
}
for (const t of tiers) {
if (!t.duration_minutes || t.price === undefined || !t.label) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'Each tier needs duration_minutes, price, and label' } })
}
}
const { getDb } = await import('../../db/client.js')
const sql = getDb()
await sql`UPDATE app_config SET value = ${sql.json({ tiers })}, updated_at = NOW() WHERE key = 'price_tiers'`
return reply.send({ success: true, data: tiers })
})
}

View File

@@ -1,10 +1,10 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
import { createPairingRequest, cancelPairingRequest, getSessionStatus } from '../../services/pairing.service.js'
import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js'
import { getActiveSessionByCustomer, endSession, getCustomerHistory } from '../../services/session.service.js'
import { subscribe } from '../../plugins/valkey.js'
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
import { requestExtension } from '../../services/extension.service.js'
import { EndedBy } from '../../constants.js'
const resolveCustomer = async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
@@ -52,7 +52,7 @@ export const clientChatRoutes = async (app) => {
})
}
if (!isValidTier(duration_minutes, price)) {
if (!(await isValidTier(duration_minutes, price))) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
@@ -63,43 +63,6 @@ export const clientChatRoutes = async (app) => {
return reply.code(201).send({ success: true, data: session })
})
app.get('/request/:sessionId/status', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { sessionId } = request.params
// SSE stream for real-time status updates
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
// Send current status immediately
const current = await getSessionStatus(sessionId)
if (current) {
reply.raw.write(`data: ${JSON.stringify(current)}\n\n`)
}
// If already in a terminal state, close
if (current && ['active', 'completed', 'cancelled', 'expired'].includes(current.status)) {
reply.raw.end()
return
}
// Subscribe to status updates
const unsubscribe = subscribe(`session:${sessionId}:status`, (data) => {
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`)
if (['paired', 'expired', 'session_ended'].includes(data.type)) {
reply.raw.end()
unsubscribe()
}
})
// Clean up on client disconnect
request.raw.on('close', () => {
unsubscribe()
})
})
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
return reply.send({ success: true, data: session })
@@ -111,7 +74,7 @@ export const clientChatRoutes = async (app) => {
})
app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await endSession(request.params.sessionId, 'customer')
const session = await endSession(request.params.sessionId, EndedBy.CUSTOMER, request.customer.id)
return reply.send({ success: true, data: session })
})

View File

@@ -2,8 +2,8 @@ import { authenticate } from '../../plugins/auth.js'
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js'
import { getActiveSessionsByMitra, endSession, getMitraHistory } from '../../services/session.service.js'
import { subscribe } from '../../plugins/valkey.js'
import { respondToExtension } from '../../services/extension.service.js'
import { EndedBy } from '../../constants.js'
const resolveMitra = async (request, reply) => {
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
@@ -23,31 +23,6 @@ const resolveMitra = async (request, reply) => {
}
export const mitraChatRoutes = async (app) => {
app.get('/incoming', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const mitraId = request.mitra.id
// SSE stream for incoming chat requests
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
// Keep-alive ping
const pingInterval = setInterval(() => {
reply.raw.write(': ping\n\n')
}, 15_000)
const unsubscribe = subscribe(`mitra:${mitraId}:requests`, (data) => {
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`)
})
request.raw.on('close', () => {
clearInterval(pingInterval)
unsubscribe()
})
})
app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id)
return reply.send({ success: true, data: session })
@@ -64,7 +39,7 @@ export const mitraChatRoutes = async (app) => {
})
app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const session = await endSession(request.params.sessionId, 'mitra')
const session = await endSession(request.params.sessionId, EndedBy.MITRA, request.mitra.id)
return reply.send({ success: true, data: session })
})

View File

@@ -4,17 +4,21 @@ import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
import { getMessages } from '../../services/chat.service.js'
import { getSessionClosures } from '../../services/closure.service.js'
import { registerDeviceToken } from '../../services/notification.service.js'
import { getDb } from '../../db/client.js'
import { UserType } from '../../constants.js'
const sql = getDb()
const resolveUser = async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
if (customer) {
request.userType = 'customer'
request.userType = UserType.CUSTOMER
request.userId = customer.id
return
}
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
if (mitra) {
request.userType = 'mitra'
request.userType = UserType.MITRA
request.userId = mitra.id
return
}
@@ -24,9 +28,25 @@ const resolveUser = async (request, reply) => {
})
}
// Verify session belongs to the authenticated user
const verifySessionOwnership = async (request, reply) => {
const { sessionId } = request.params
const [session] = await sql`
SELECT id FROM chat_sessions
WHERE id = ${sessionId}
AND (customer_id = ${request.userId} OR mitra_id = ${request.userId})
`
if (!session) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'You do not have access to this session' },
})
}
}
export const sharedChatRoutes = async (app) => {
// Get messages for a session (paginated)
app.get('/chat/:sessionId/messages', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
app.get('/chat/:sessionId/messages', { preHandler: [authenticate, resolveUser, verifySessionOwnership] }, async (request, reply) => {
const { sessionId } = request.params
const { limit, before } = request.query
const messages = await getMessages(sessionId, {
@@ -37,7 +57,7 @@ export const sharedChatRoutes = async (app) => {
})
// Get session info
app.get('/chat/:sessionId/info', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
app.get('/chat/:sessionId/info', { preHandler: [authenticate, resolveUser, verifySessionOwnership] }, async (request, reply) => {
const { sessionId } = request.params
const { getSessionById } = await import('../../services/session.service.js')
const session = await getSessionById(sessionId)
@@ -48,7 +68,7 @@ export const sharedChatRoutes = async (app) => {
})
// Get full transcript (read-only, for history)
app.get('/chat/:sessionId/transcript', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
app.get('/chat/:sessionId/transcript', { preHandler: [authenticate, resolveUser, verifySessionOwnership] }, async (request, reply) => {
const { sessionId } = request.params
const messages = await getMessages(sessionId, { limit: 10000 })
const closures = await getSessionClosures(sessionId)
@@ -66,7 +86,7 @@ export const sharedChatRoutes = async (app) => {
})
// Submit goodbye/closure message
app.post('/sessions/:sessionId/close-message', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
app.post('/sessions/:sessionId/close-message', { preHandler: [authenticate, resolveUser, verifySessionOwnership] }, async (request, reply) => {
const { sessionId } = request.params
const { message } = request.body
if (!message) {

View File

@@ -2,6 +2,7 @@ import { subscribe } from '../plugins/valkey.js'
import { sendMessage, markDelivered, markRead } from './chat.service.js'
import { initiateEarlyEnd } from './closure.service.js'
import { sendToSessionParticipant } from '../plugins/websocket.js'
import { UserType, MessageType, WsMessage } from '../constants.js'
// Track typing throttle per session+user
const typingLastSent = new Map()
@@ -18,36 +19,36 @@ export const startSessionListener = (sessionId) => {
try {
switch (type) {
case 'message':
case WsMessage.MESSAGE:
await sendMessage({
sessionId: _session_id,
senderType: _sender_type,
senderId: _sender_id,
content: payload.content,
type: payload.message_type || 'text',
type: payload.message_type || MessageType.TEXT,
})
break
case 'typing':
case WsMessage.TYPING:
handleTyping(_session_id, _sender_type)
break
case 'delivered':
case WsMessage.DELIVERED:
await markDelivered(_session_id, _sender_type, payload.message_ids)
break
case 'read':
case WsMessage.READ:
await markRead(_session_id, _sender_type, payload.message_ids)
break
case 'early_end':
case WsMessage.EARLY_END:
await initiateEarlyEnd(_session_id, _sender_type)
break
}
} catch (err) {
console.error(`[chat-handler] Error processing ${type}:`, err.message)
sendToSessionParticipant(_session_id, _sender_type, {
type: 'error',
type: WsMessage.ERROR,
message: err.message,
code: err.code,
})
@@ -74,9 +75,9 @@ const handleTyping = (sessionId, senderType) => {
typingLastSent.set(key, now)
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
sendToSessionParticipant(sessionId, recipientType, {
type: 'typing',
type: WsMessage.TYPING,
sender_type: senderType,
})
}

View File

@@ -2,14 +2,15 @@ import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
import { isUserOnlineWs, sendToSessionParticipant } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js'
import { UserType, SessionStatus, MessageStatus, MessageType, WsMessage } from '../constants.js'
const sql = getDb()
export const sendMessage = async ({ sessionId, senderType, senderId, content, type = 'text' }) => {
export const sendMessage = async ({ sessionId, senderType, senderId, content, type = MessageType.TEXT }) => {
// Verify session is active
const [session] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
WHERE id = ${sessionId} AND status = 'active'
WHERE id = ${sessionId} AND status = ${SessionStatus.ACTIVE}
`
if (!session) {
throw Object.assign(new Error('Session is not active'), {
@@ -20,25 +21,25 @@ export const sendMessage = async ({ sessionId, senderType, senderId, content, ty
// Save message
const [message] = await sql`
INSERT INTO chat_messages (session_id, sender_type, sender_id, type, content, status)
VALUES (${sessionId}, ${senderType}, ${senderId}, ${type}, ${content}, 'sent')
VALUES (${sessionId}, ${senderType}, ${senderId}, ${type}, ${content}, ${MessageStatus.SENT})
RETURNING id, session_id, sender_type, sender_id, type, content, status, created_at
`
// Send ack to sender
sendToSessionParticipant(sessionId, senderType, {
type: 'message_ack',
type: WsMessage.MESSAGE_ACK,
message_id: message.id,
status: 'sent',
status: MessageStatus.SENT,
created_at: message.created_at,
})
// Determine recipient
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
const recipientId = senderType === 'customer' ? session.mitra_id : session.customer_id
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
const recipientId = senderType === UserType.CUSTOMER ? session.mitra_id : session.customer_id
// Try to send via WebSocket
const delivered = sendToSessionParticipant(sessionId, recipientType, {
type: 'message',
type: WsMessage.MESSAGE,
message_id: message.id,
sender_type: senderType,
content: message.content,
@@ -49,7 +50,7 @@ export const sendMessage = async ({ sessionId, senderType, senderId, content, ty
// If recipient not connected via WebSocket, send FCM push
if (!delivered && recipientId) {
await sendPushNotification(recipientType, recipientId, {
title: senderType === 'customer' ? 'Pesan baru dari Customer' : 'Pesan baru dari Bestie',
title: senderType === UserType.CUSTOMER ? 'Pesan baru dari Customer' : 'Pesan baru dari Bestie',
body: content.length > 100 ? content.substring(0, 100) + '...' : content,
data: { session_id: sessionId, type: 'chat_message' },
})
@@ -63,18 +64,18 @@ export const markDelivered = async (sessionId, senderType, messageIds) => {
await sql`
UPDATE chat_messages
SET status = 'delivered', delivered_at = NOW()
SET status = ${MessageStatus.DELIVERED}, delivered_at = NOW()
WHERE id = ANY(${messageIds})
AND session_id = ${sessionId}
AND status = 'sent'
AND status = ${MessageStatus.SENT}
`
// Notify sender about delivery
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
sendToSessionParticipant(sessionId, recipientType, {
type: 'message_status',
type: WsMessage.MESSAGE_STATUS,
message_ids: messageIds,
status: 'delivered',
status: MessageStatus.DELIVERED,
})
}
@@ -83,18 +84,18 @@ export const markRead = async (sessionId, senderType, messageIds) => {
await sql`
UPDATE chat_messages
SET status = 'read', read_at = NOW()
SET status = ${MessageStatus.READ}, read_at = NOW()
WHERE id = ANY(${messageIds})
AND session_id = ${sessionId}
AND status IN ('sent', 'delivered')
AND status IN (${MessageStatus.SENT}, ${MessageStatus.DELIVERED})
`
// Notify sender about read
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
sendToSessionParticipant(sessionId, recipientType, {
type: 'message_status',
type: WsMessage.MESSAGE_STATUS,
message_ids: messageIds,
status: 'read',
status: MessageStatus.READ,
})
}
@@ -115,13 +116,13 @@ export const getMessages = async (sessionId, { limit = 50, before } = {}) => {
}
export const getUndeliveredMessages = async (sessionId, recipientType) => {
const senderType = recipientType === 'customer' ? 'mitra' : 'customer'
const senderType = recipientType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
return sql`
SELECT id, session_id, sender_type, sender_id, type, content, status, created_at
FROM chat_messages
WHERE session_id = ${sessionId}
AND sender_type = ${senderType}
AND status = 'sent'
AND status = ${MessageStatus.SENT}
ORDER BY created_at ASC
`
}

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
}

View File

@@ -1,18 +1,19 @@
import { getDb } from '../db/client.js'
import { SessionStatus } from '../constants.js'
const sql = getDb()
export const getDashboardStats = async () => {
const [[{ active_chats }], [{ online_mitras }], [{ pending_requests }]] = await Promise.all([
sql`SELECT COUNT(*) AS active_chats FROM chat_sessions WHERE status IN ('active', 'pending_payment')`,
sql`SELECT COUNT(*) AS active_chats FROM chat_sessions WHERE status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})`,
sql`SELECT COUNT(*) AS online_mitras FROM mitra_online_status WHERE is_online = true`,
sql`SELECT COUNT(*) AS pending_requests FROM chat_sessions WHERE status IN ('searching', 'pending_acceptance')`,
sql`SELECT COUNT(*) AS pending_requests FROM chat_sessions WHERE status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})`,
])
const customersPerMitra = await sql`
SELECT m.id, m.display_name,
(SELECT COUNT(*) FROM chat_sessions cs
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count
WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})) AS active_session_count
FROM mitras m
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
WHERE s.is_online = true

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,
})
}

View File

@@ -1,4 +1,5 @@
import { getDb } from '../db/client.js'
import { SessionStatus } from '../constants.js'
const sql = getDb()
@@ -64,7 +65,7 @@ export const getOnlineMitras = async () => {
const mitras = await sql`
SELECT m.id, m.display_name, m.phone, s.last_online_at, s.updated_at,
(SELECT COUNT(*) FROM chat_sessions cs
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count
WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})) AS active_session_count
FROM mitras m
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
WHERE s.is_online = true AND m.is_active = true

View File

@@ -1,10 +1,11 @@
import admin from 'firebase-admin'
import { getDb } from '../db/client.js'
import { UserType } from '../constants.js'
const sql = getDb()
export const registerDeviceToken = async (userType, userId, fcmToken) => {
const table = userType === 'customer' ? 'customers' : 'mitras'
const table = userType === UserType.CUSTOMER ? 'customers' : 'mitras'
await sql`
UPDATE ${sql(table)}
SET fcm_token = ${fcmToken}
@@ -13,7 +14,7 @@ export const registerDeviceToken = async (userType, userId, fcmToken) => {
}
export const sendPushNotification = async (recipientType, recipientId, { title, body, data = {} }) => {
const table = recipientType === 'customer' ? 'customers' : 'mitras'
const table = recipientType === UserType.CUSTOMER ? 'customers' : 'mitras'
const [user] = await sql`
SELECT fcm_token FROM ${sql(table)} WHERE id = ${recipientId}
`

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,
})
}

View File

@@ -2,8 +2,9 @@ import { getDb } from '../db/client.js'
const sql = getDb()
// Mock price tiers (will come from Control Center config later)
const PRICE_TIERS = [
// Default tiers as fallback
const DEFAULT_TIERS = [
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
@@ -11,10 +12,14 @@ const PRICE_TIERS = [
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
]
export const getPriceTiers = () => PRICE_TIERS
export const getPriceTiers = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'price_tiers'`
return row?.value?.tiers ?? DEFAULT_TIERS
}
export const isValidTier = (durationMinutes, price) => {
return PRICE_TIERS.some(
export const isValidTier = async (durationMinutes, price) => {
const tiers = await getPriceTiers()
return tiers.some(
(t) => t.duration_minutes === durationMinutes && t.price === price
)
}
@@ -41,7 +46,7 @@ export const isCustomerEligibleForFreeTrial = async (customerId) => {
}
export const getPricingForCustomer = async (customerId) => {
const tiers = getPriceTiers()
const tiers = await getPriceTiers()
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
const freeTrial = await getFreeTrial()

View File

@@ -1,6 +1,7 @@
import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
import { sendToSessionParticipant } from '../plugins/websocket.js'
import { UserType, SessionStatus, WsMessage } from '../constants.js'
const sql = getDb()
@@ -62,34 +63,101 @@ export const extendSessionTimer = async (sessionId, additionalMinutes) => {
}
const onSessionWarning = (sessionId) => {
const data = { type: 'session_timer', remaining_seconds: 60, session_id: sessionId }
sendToSessionParticipant(sessionId, 'customer', data)
sendToSessionParticipant(sessionId, 'mitra', data)
const data = { type: WsMessage.SESSION_TIMER, remaining_seconds: 60, session_id: sessionId }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
sendToSessionParticipant(sessionId, UserType.MITRA, data)
}
// Grace period timers for auto-completing abandoned sessions
const closureGraceTimers = new Map()
const CLOSURE_GRACE_PERIOD_MS = 5 * 60_000 // 5 minutes
const onSessionExpired = async (sessionId) => {
clearSessionTimer(sessionId)
// Check session is still active
// Move session to closing status
const [session] = await sql`
SELECT id, status FROM chat_sessions WHERE id = ${sessionId} AND status = 'active'
UPDATE chat_sessions
SET status = ${SessionStatus.CLOSING}
WHERE id = ${sessionId} AND status = ${SessionStatus.ACTIVE}
RETURNING id, customer_id, mitra_id
`
if (!session) return
// Notify both parties
const data = { type: 'session_expired', session_id: sessionId }
sendToSessionParticipant(sessionId, 'customer', data)
sendToSessionParticipant(sessionId, 'mitra', data)
// Notify customer — sees extend/close dialog
const expiredData = { type: WsMessage.SESSION_EXPIRED, session_id: sessionId }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, expiredData)
// Notify mitra — sees expired + closing (waits for customer's decision or goodbye)
sendToSessionParticipant(sessionId, UserType.MITRA, expiredData)
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.SESSION_CLOSING, session_id: sessionId,
})
// Also publish via Valkey for any listeners
await publish(`session:${sessionId}:status`, data)
await publish(`session:${sessionId}:status`, expiredData)
// Start grace period — auto-complete if closing messages aren't submitted
const graceTimerId = setTimeout(async () => {
closureGraceTimers.delete(sessionId)
try {
const [stale] = await sql`
SELECT id FROM chat_sessions
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
`
if (stale) {
await sql`
UPDATE chat_sessions
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = 'system'
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
`
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
sendToSessionParticipant(sessionId, UserType.MITRA, data)
console.log(`Auto-completed abandoned session ${sessionId} after grace period`)
}
} catch (_) {}
}, CLOSURE_GRACE_PERIOD_MS)
closureGraceTimers.set(sessionId, graceTimerId)
}
export const clearClosureGraceTimer = (sessionId) => {
const timerId = closureGraceTimers.get(sessionId)
if (timerId) {
clearTimeout(timerId)
closureGraceTimers.delete(sessionId)
}
}
// Restore timers for active sessions on server restart
export const restoreActiveTimers = async () => {
// Expire sessions that already passed their expires_at while server was down
const staleSessions = await sql`
UPDATE chat_sessions
SET status = ${SessionStatus.COMPLETED}, ended_at = expires_at, ended_by = 'system'
WHERE status = ${SessionStatus.ACTIVE} AND expires_at IS NOT NULL AND expires_at <= NOW()
RETURNING id
`
if (staleSessions.length > 0) {
console.log(`Auto-completed ${staleSessions.length} expired session(s)`)
}
// Auto-complete sessions stuck in 'closing' status (abandoned during grace period)
const staleClosing = await sql`
UPDATE chat_sessions
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = 'system'
WHERE status = ${SessionStatus.CLOSING}
RETURNING id
`
if (staleClosing.length > 0) {
console.log(`Auto-completed ${staleClosing.length} abandoned closing session(s)`)
}
// Restore timers for sessions still within their time window
const activeSessions = await sql`
SELECT id, expires_at FROM chat_sessions
WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at > NOW()
WHERE status = ${SessionStatus.ACTIVE} AND expires_at IS NOT NULL AND expires_at > NOW()
`
for (const session of activeSessions) {
startSessionTimer(session.id, session.expires_at)

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 }
}