Phase 3 scaffold: chat engine (WebSocket, FCM, pricing, timer, extension, history)

- Backend: WebSocket plugin, chat/pricing/timer/extension/closure/notification services
- Client app: ChatBloc, pricing dialog, chat screen with message status, extension/goodbye flow, history
- Mitra app: MitraChatBloc, ExtensionBloc, chat screen, extension accept/reject, history
- Control center: free trial, extension timeout, early end config toggles
- DB migration: chat_messages, session_closures, session_extensions, customer_transactions tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:58:11 +08:00
parent 844d7234e6
commit b4efcf14c2
47 changed files with 4361 additions and 44 deletions

View File

@@ -0,0 +1,82 @@
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'
// Track typing throttle per session+user
const typingLastSent = new Map()
const TYPING_THROTTLE_MS = 2000
// Active session listeners: sessionId → unsubscribe
const sessionListeners = new Map()
export const startSessionListener = (sessionId) => {
if (sessionListeners.has(sessionId)) return
const unsub = subscribe(`session:${sessionId}:incoming`, async (data) => {
const { type, _sender_type, _sender_id, _session_id, ...payload } = data
try {
switch (type) {
case 'message':
await sendMessage({
sessionId: _session_id,
senderType: _sender_type,
senderId: _sender_id,
content: payload.content,
type: payload.message_type || 'text',
})
break
case 'typing':
handleTyping(_session_id, _sender_type)
break
case 'delivered':
await markDelivered(_session_id, _sender_type, payload.message_ids)
break
case 'read':
await markRead(_session_id, _sender_type, payload.message_ids)
break
case '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',
message: err.message,
code: err.code,
})
}
})
sessionListeners.set(sessionId, unsub)
}
export const stopSessionListener = (sessionId) => {
const unsub = sessionListeners.get(sessionId)
if (unsub) {
unsub()
sessionListeners.delete(sessionId)
}
}
const handleTyping = (sessionId, senderType) => {
const key = `${sessionId}:${senderType}`
const now = Date.now()
const lastSent = typingLastSent.get(key) || 0
if (now - lastSent < TYPING_THROTTLE_MS) return
typingLastSent.set(key, now)
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
sendToSessionParticipant(sessionId, recipientType, {
type: 'typing',
sender_type: senderType,
})
}

View File

@@ -0,0 +1,127 @@
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'
const sql = getDb()
export const sendMessage = async ({ sessionId, senderType, senderId, content, type = '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'
`
if (!session) {
throw Object.assign(new Error('Session is not active'), {
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
})
}
// 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')
RETURNING id, session_id, sender_type, sender_id, type, content, status, created_at
`
// Send ack to sender
sendToSessionParticipant(sessionId, senderType, {
type: 'message_ack',
message_id: message.id,
status: 'sent',
created_at: message.created_at,
})
// Determine recipient
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
const recipientId = senderType === 'customer' ? session.mitra_id : session.customer_id
// Try to send via WebSocket
const delivered = sendToSessionParticipant(sessionId, recipientType, {
type: 'message',
message_id: message.id,
sender_type: senderType,
content: message.content,
message_type: message.type,
created_at: message.created_at,
})
// 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',
body: content.length > 100 ? content.substring(0, 100) + '...' : content,
data: { session_id: sessionId, type: 'chat_message' },
})
}
return message
}
export const markDelivered = async (sessionId, senderType, messageIds) => {
if (!messageIds || messageIds.length === 0) return
await sql`
UPDATE chat_messages
SET status = 'delivered', delivered_at = NOW()
WHERE id = ANY(${messageIds})
AND session_id = ${sessionId}
AND status = 'sent'
`
// Notify sender about delivery
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
sendToSessionParticipant(sessionId, recipientType, {
type: 'message_status',
message_ids: messageIds,
status: 'delivered',
})
}
export const markRead = async (sessionId, senderType, messageIds) => {
if (!messageIds || messageIds.length === 0) return
await sql`
UPDATE chat_messages
SET status = 'read', read_at = NOW()
WHERE id = ANY(${messageIds})
AND session_id = ${sessionId}
AND status IN ('sent', 'delivered')
`
// Notify sender about read
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
sendToSessionParticipant(sessionId, recipientType, {
type: 'message_status',
message_ids: messageIds,
status: 'read',
})
}
export const getMessages = async (sessionId, { limit = 50, before } = {}) => {
const conditions = before
? sql`AND created_at < ${before}`
: sql``
const messages = await sql`
SELECT id, session_id, sender_type, sender_id, type, content, status, delivered_at, read_at, created_at
FROM chat_messages
WHERE session_id = ${sessionId}
${conditions}
ORDER BY created_at DESC
LIMIT ${limit}
`
return messages.reverse() // Return in chronological order
}
export const getUndeliveredMessages = async (sessionId, recipientType) => {
const senderType = recipientType === 'customer' ? 'mitra' : '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'
ORDER BY created_at ASC
`
}

View File

@@ -0,0 +1,106 @@
import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
import { clearSessionTimer } from './session-timer.service.js'
import { sendToSessionParticipant } from '../plugins/websocket.js'
const sql = getDb()
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')
`
if (!session) {
throw Object.assign(new Error('Session not found or already completed'), {
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
})
}
// Save closure message
const [closure] = await sql`
INSERT INTO session_closures (session_id, user_type, user_id, message)
VALUES (${sessionId}, ${userType}, ${userId}, ${message})
ON CONFLICT DO NOTHING
RETURNING id, session_id, user_type, message, created_at
`
// Check if both parties have submitted
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')
if (hasCustomer && hasMitra) {
// Both submitted — complete the session
await completeSession(sessionId)
}
return closure
}
export const completeSession = async (sessionId) => {
clearSessionTimer(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')
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)
await publish(`session:${sessionId}:status`, { type: '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 [configRow] = await sql`SELECT value FROM app_config WHERE key = ${configKey}`
const enabled = configRow?.value?.value ?? false
if (!enabled) {
throw Object.assign(new Error('Early end is not enabled'), {
code: 'EARLY_END_DISABLED', statusCode: 403,
})
}
// Move session to closing
const [session] = await sql`
UPDATE chat_sessions
SET status = 'closing', ended_by = ${userType}
WHERE id = ${sessionId} AND status = 'active'
RETURNING id, customer_id, mitra_id
`
if (!session) {
throw Object.assign(new Error('Session not active'), {
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
})
}
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)
return session
}
export const getSessionClosures = async (sessionId) => {
return sql`
SELECT user_type, message, created_at
FROM session_closures
WHERE session_id = ${sessionId}
ORDER BY created_at ASC
`
}

View File

@@ -29,3 +29,73 @@ export const setMaxCustomersPerMitra = async (value) => {
`
return { max_customers_per_mitra: value }
}
// --- Phase 3 config ---
export const getFreeTrialConfig = async () => {
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
return {
enabled: enabledRow?.value?.value ?? false,
duration_minutes: durationRow?.value?.value ?? 5,
}
}
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
if (enabled !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('free_trial_enabled', ${sql.json({ value: enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (duration_minutes !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('free_trial_duration_minutes', ${sql.json({ value: duration_minutes })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getFreeTrialConfig()
}
export const getExtensionTimeoutConfig = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'`
return { extension_timeout_seconds: row?.value?.value ?? 60 }
}
export const setExtensionTimeoutConfig = async (seconds) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('extension_timeout_seconds', ${sql.json({ value: seconds })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { extension_timeout_seconds: seconds }
}
export const getEarlyEndConfig = async () => {
const [mitraRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_mitra_enabled'`
const [customerRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_customer_enabled'`
return {
mitra_enabled: mitraRow?.value?.value ?? false,
customer_enabled: customerRow?.value?.value ?? false,
}
}
export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => {
if (mitra_enabled !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('early_end_mitra_enabled', ${sql.json({ value: mitra_enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (customer_enabled !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('early_end_customer_enabled', ${sql.json({ value: customer_enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getEarlyEndConfig()
}

View File

@@ -0,0 +1,159 @@
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'
const sql = getDb()
// Extension timeout map: extensionId → timeoutId
const extensionTimeouts = new Map()
const getExtensionTimeout = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'`
return (row?.value?.value ?? 60) * 1000 // Convert to ms
}
export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => {
// Verify session belongs to customer and just expired
const [session] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
WHERE id = ${sessionId} AND customer_id = ${customerId}
`
if (!session) {
throw Object.assign(new Error('Session not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
// 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')
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}`
// Notify mitra
sendToSessionParticipant(sessionId, 'mitra', {
type: 'extension_request',
extension_id: extension.id,
session_id: sessionId,
duration_minutes,
price,
})
// Notify customer that chat is paused
sendToSessionParticipant(sessionId, 'customer', {
type: 'session_paused',
session_id: sessionId,
reason: 'extension_pending',
})
// Start timeout
const timeoutMs = await getExtensionTimeout()
const timeoutId = setTimeout(async () => {
try {
await timeoutExtension(extension.id, sessionId)
} catch (_) {}
}, timeoutMs)
extensionTimeouts.set(extension.id, timeoutId)
return extension
}
export const respondToExtension = async (extensionId, sessionId, mitraId, accepted) => {
const status = accepted ? 'accepted' : 'rejected'
const [extension] = await sql`
UPDATE session_extensions
SET status = ${status}, responded_at = NOW()
WHERE id = ${extensionId} AND status = 'pending'
RETURNING id, session_id, requested_duration_minutes, requested_price, status
`
if (!extension) {
throw Object.assign(new Error('Extension not found or already resolved'), {
code: 'EXTENSION_RESOLVED', statusCode: 409,
})
}
// Clear timeout
const timeoutId = extensionTimeouts.get(extensionId)
if (timeoutId) {
clearTimeout(timeoutId)
extensionTimeouts.delete(extensionId)
}
if (accepted) {
// 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}`
// Record transaction
await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
SELECT customer_id, id, 'extension', ${extension.requested_price}
FROM chat_sessions WHERE id = ${extension.session_id}
`
// Notify both parties
sendToSessionParticipant(sessionId, 'customer', {
type: 'extension_response',
accepted: true,
duration_minutes: extension.requested_duration_minutes,
})
sendToSessionParticipant(sessionId, 'mitra', {
type: 'session_resumed',
session_id: sessionId,
})
} else {
// Rejected — proceed to closure
await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}`
sendToSessionParticipant(sessionId, 'customer', {
type: 'extension_response',
accepted: false,
})
sendToSessionParticipant(sessionId, 'mitra', {
type: 'session_closing',
session_id: sessionId,
})
sendToSessionParticipant(sessionId, 'customer', {
type: 'session_closing',
session_id: sessionId,
})
}
return extension
}
const timeoutExtension = async (extensionId, sessionId) => {
extensionTimeouts.delete(extensionId)
const [extension] = await sql`
UPDATE session_extensions
SET status = 'timeout', responded_at = NOW()
WHERE id = ${extensionId} AND status = '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}`
sendToSessionParticipant(sessionId, 'customer', {
type: 'extension_response',
accepted: false,
reason: 'timeout',
})
sendToSessionParticipant(sessionId, 'mitra', {
type: 'session_closing',
session_id: sessionId,
})
sendToSessionParticipant(sessionId, 'customer', {
type: 'session_closing',
session_id: sessionId,
})
}

View File

@@ -0,0 +1,52 @@
import admin from 'firebase-admin'
import { getDb } from '../db/client.js'
const sql = getDb()
export const registerDeviceToken = async (userType, userId, fcmToken) => {
const table = userType === 'customer' ? 'customers' : 'mitras'
await sql`
UPDATE ${sql(table)}
SET fcm_token = ${fcmToken}
WHERE id = ${userId}
`
}
export const sendPushNotification = async (recipientType, recipientId, { title, body, data = {} }) => {
const table = recipientType === 'customer' ? 'customers' : 'mitras'
const [user] = await sql`
SELECT fcm_token FROM ${sql(table)} WHERE id = ${recipientId}
`
if (!user?.fcm_token) return false
try {
await admin.messaging().send({
token: user.fcm_token,
notification: { title, body },
data: {
...Object.fromEntries(
Object.entries(data).map(([k, v]) => [k, String(v)])
),
click_action: 'FLUTTER_NOTIFICATION_CLICK',
},
android: {
priority: 'high',
notification: { channelId: 'chat_messages' },
},
apns: {
payload: {
aps: { sound: 'default', badge: 1 },
},
},
})
return true
} catch (err) {
console.error(`[FCM] Failed to send to ${recipientType}:${recipientId}:`, err.message)
// Clear invalid token
if (err.code === 'messaging/registration-token-not-registered') {
await sql`UPDATE ${sql(table)} SET fcm_token = NULL WHERE id = ${recipientId}`
}
return false
}
}

View File

@@ -1,6 +1,8 @@
import { getDb } from '../db/client.js'
import { getMaxCustomersPerMitra } from './config.service.js'
import { publish } from '../plugins/valkey.js'
import { startSessionTimer } from './session-timer.service.js'
import { startSessionListener } from './chat-handler.service.js'
const sql = getDb()
@@ -23,7 +25,7 @@ export const findAvailableMitras = async () => {
return mitras
}
export const createPairingRequest = async (customerId) => {
export const createPairingRequest = async (customerId, { duration_minutes, price, is_free_trial } = {}) => {
// Check for existing active session or request
const [existing] = await sql`
SELECT id, status FROM chat_sessions
@@ -43,11 +45,11 @@ export const createPairingRequest = async (customerId) => {
})
}
// Create session
// Create session with duration/price
const [session] = await sql`
INSERT INTO chat_sessions (customer_id, status)
VALUES (${customerId}, 'pending_acceptance')
RETURNING id, customer_id, status, created_at
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})
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, created_at
`
// Create notifications for all available mitras
@@ -111,13 +113,35 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
pairingTimeouts.delete(sessionId)
}
// Auto-skip payment for now: move to active
// Auto-skip payment for now: move to active and set expires_at
const [activeSession] = await sql`
UPDATE chat_sessions SET status = 'active'
UPDATE chat_sessions
SET status = 'active',
expires_at = CASE
WHEN duration_minutes IS NOT NULL THEN NOW() + (duration_minutes || ' minutes')::interval
ELSE NULL
END
WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status, paired_at
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at
`
// Record transaction
if (activeSession.duration_minutes) {
const txType = activeSession.is_free_trial ? 'free_trial' : 'paid'
await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
`
}
// Start session timer if duration is set
if (activeSession.expires_at) {
startSessionTimer(sessionId, activeSession.expires_at)
}
// Start chat message listener for this session
startSessionListener(sessionId)
// Get mitra display name for customer notification
const [mitra] = await sql`
SELECT display_name FROM mitras WHERE id = ${mitraId}

View File

@@ -0,0 +1,54 @@
import { getDb } from '../db/client.js'
const sql = getDb()
// Mock price tiers (will come from Control Center config later)
const PRICE_TIERS = [
{ 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' },
]
export const getPriceTiers = () => PRICE_TIERS
export const isValidTier = (durationMinutes, price) => {
return PRICE_TIERS.some(
(t) => t.duration_minutes === durationMinutes && t.price === price
)
}
export const getFreeTrial = async () => {
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
return {
enabled: enabledRow?.value?.value ?? false,
duration_minutes: durationRow?.value?.value ?? 5,
}
}
export const isCustomerEligibleForFreeTrial = async (customerId) => {
const freeTrial = await getFreeTrial()
if (!freeTrial.enabled) return false
const [tx] = await sql`
SELECT id FROM customer_transactions
WHERE customer_id = ${customerId}
LIMIT 1
`
return !tx // Eligible only if no transactions at all
}
export const getPricingForCustomer = async (customerId) => {
const tiers = getPriceTiers()
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
const freeTrial = await getFreeTrial()
return {
tiers,
free_trial: freeTrialEligible
? { eligible: true, duration_minutes: freeTrial.duration_minutes }
: { eligible: false },
}
}

View File

@@ -0,0 +1,100 @@
import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
import { sendToSessionParticipant } from '../plugins/websocket.js'
const sql = getDb()
// Active session timers: sessionId → { warningTimeout, expiryTimeout }
const sessionTimers = new Map()
export const startSessionTimer = (sessionId, expiresAt) => {
const now = Date.now()
const expiresMs = new Date(expiresAt).getTime()
const warningMs = expiresMs - 60_000 // 1 minute before expiry
// Clear any existing timers
clearSessionTimer(sessionId)
const timers = {}
// Warning timer (1 min before expiry)
if (warningMs > now) {
timers.warningTimeout = setTimeout(() => {
onSessionWarning(sessionId)
}, warningMs - now)
}
// Expiry timer
if (expiresMs > now) {
timers.expiryTimeout = setTimeout(() => {
onSessionExpired(sessionId)
}, expiresMs - now)
} else {
// Already expired
onSessionExpired(sessionId)
return
}
sessionTimers.set(sessionId, timers)
}
export const clearSessionTimer = (sessionId) => {
const timers = sessionTimers.get(sessionId)
if (timers) {
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
sessionTimers.delete(sessionId)
}
}
export const extendSessionTimer = async (sessionId, additionalMinutes) => {
const [session] = await sql`
UPDATE chat_sessions
SET expires_at = expires_at + ${additionalMinutes + ' minutes'}::interval,
extended_minutes = extended_minutes + ${additionalMinutes}
WHERE id = ${sessionId}
RETURNING id, expires_at
`
if (session) {
startSessionTimer(sessionId, session.expires_at)
}
return session
}
const onSessionWarning = (sessionId) => {
const data = { type: 'session_timer', remaining_seconds: 60, session_id: sessionId }
sendToSessionParticipant(sessionId, 'customer', data)
sendToSessionParticipant(sessionId, 'mitra', data)
}
const onSessionExpired = async (sessionId) => {
clearSessionTimer(sessionId)
// Check session is still active
const [session] = await sql`
SELECT id, status FROM chat_sessions WHERE id = ${sessionId} AND status = 'active'
`
if (!session) return
// Notify both parties
const data = { type: 'session_expired', session_id: sessionId }
sendToSessionParticipant(sessionId, 'customer', data)
sendToSessionParticipant(sessionId, 'mitra', data)
// Also publish via Valkey for any listeners
await publish(`session:${sessionId}:status`, data)
}
// Restore timers for active sessions on server restart
export const restoreActiveTimers = async () => {
const activeSessions = await sql`
SELECT id, expires_at FROM chat_sessions
WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at > NOW()
`
for (const session of activeSessions) {
startSessionTimer(session.id, session.expires_at)
}
if (activeSessions.length > 0) {
console.log(`Restored ${activeSessions.length} session timer(s)`)
}
}

View File

@@ -6,11 +6,12 @@ const sql = getDb()
export const getActiveSessionByCustomer = async (customerId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
m.display_name AS mitra_display_name
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')
AND cs.status IN ('active', 'pending_payment', 'extending', 'closing')
ORDER BY cs.created_at DESC LIMIT 1
`
return session
@@ -19,11 +20,12 @@ export const getActiveSessionByCustomer = async (customerId) => {
export const getActiveSessionsByMitra = async (mitraId) => {
const sessions = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.expires_at, cs.extended_minutes,
c.display_name AS customer_display_name
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')
AND cs.status IN ('active', 'pending_payment', 'extending', 'closing')
ORDER BY cs.created_at DESC
`
return sessions
@@ -138,6 +140,7 @@ export const listSessions = async ({ page = 1, limit = 20, status } = {}) => {
export const getSessionById = async (sessionId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
FROM chat_sessions cs
@@ -147,3 +150,45 @@ export const getSessionById = async (sessionId) => {
`
return session
}
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit
const items = await sql`
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
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.customer_id = ${customerId}
AND cs.status = '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'
`
return { items, total: Number(count), page, limit }
}
export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit
const items = await sql`
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
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
WHERE cs.mitra_id = ${mitraId}
AND cs.status = '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'
`
return { items, total: Number(count), page, limit }
}