import { getDb } from '../db/client.js' import { getMaxCustomersPerMitra } from './config.service.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! Ketuk untuk menerima.', data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id, action: 'open_accept' }, }) } } } // Send notification to customer via WebSocket, fall back to FCM if offline const notifyCustomer = async (customerId, data) => { const sent = sendToUser(UserType.CUSTOMER, customerId, data) console.log(`[notifyCustomer] customerId=${customerId} type=${data.type} ws_sent=${sent}`) 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` SELECT m.id, m.display_name FROM mitras m INNER JOIN mitra_online_status s ON s.mitra_id = m.id WHERE m.is_active = true AND s.is_online = true AND ( SELECT COUNT(*) FROM chat_sessions cs WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) ) < ${max_customers_per_mitra} ` return mitras } 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 WHERE customer_id = ${customerId} 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'), { code: 'ALREADY_ACTIVE', statusCode: 409, }) } const availableMitras = await findAvailableMitras() if (availableMitras.length === 0) { throw Object.assign(new Error('No bestie available'), { code: 'NO_MITRA_AVAILABLE', statusCode: 404, }) } // Create session with duration/price const [session] = await sql` INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial) 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 ` // Create notifications for all available mitras for (const mitra of availableMitras) { const [{ count: activeCount }] = await sql` SELECT COUNT(*)::int AS count FROM chat_sessions WHERE mitra_id = ${mitra.id} AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) ` await sql` INSERT INTO chat_request_notifications (session_id, mitra_id, active_session_count) VALUES (${session.id}, ${mitra.id}, ${activeCount}) ` // 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, duration_minutes: session.duration_minutes, is_free_trial: session.is_free_trial, }) } // Start 60s timeout const timeoutId = setTimeout(async () => { try { await expirePairingRequest(session.id) } catch (_) {} }, 60_000) pairingTimeouts.set(session.id, timeoutId) return session } 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 = ${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 ` if (!session) { throw Object.assign(new Error('Request already accepted or expired'), { code: 'REQUEST_UNAVAILABLE', statusCode: 409, }) } // Mark this mitra's notification as accepted await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.ACCEPTED}, responded_at = NOW() WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} ` // Mark other mitras' notifications as missed (another mitra accepted) await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.MISSED}, responded_at = NOW() WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL ` // Clear timeout const timeoutId = pairingTimeouts.get(sessionId) if (timeoutId) { clearTimeout(timeoutId) pairingTimeouts.delete(sessionId) } // Auto-skip payment for now: move to active and set expires_at const [activeSession] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.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, duration_minutes, price, is_free_trial, expires_at ` // Record transaction if (activeSession.duration_minutes) { 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}) ` } // 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} ` // Notify customer via WebSocket (FCM fallback) await notifyCustomer(activeSession.customer_id, { type: WsMessage.PAIRED, session_id: sessionId, mitra_display_name: mitra.display_name, status: SessionStatus.ACTIVE, }) // Notify other mitras to dismiss the request const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} ` for (const n of notifications) { await notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, reason: 'accepted_by_other', }) } return activeSession } export const declinePairingRequest = async (sessionId, mitraId) => { await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.DECLINED}, responded_at = NOW() WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL ` } export const cancelPairingRequest = async (sessionId, customerId) => { const [session] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.CANCELLED} WHERE id = ${sessionId} AND customer_id = ${customerId} AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) RETURNING id, status ` if (!session) { throw Object.assign(new Error('Cannot cancel this request'), { code: 'CANNOT_CANCEL', statusCode: 409, }) } // Clear timeout const timeoutId = pairingTimeouts.get(sessionId) if (timeoutId) { clearTimeout(timeoutId) pairingTimeouts.delete(sessionId) } // Mark all notifications as ignored await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.IGNORED}, responded_at = NOW() WHERE session_id = ${sessionId} AND response IS NULL ` // Notify mitras to dismiss (customer cancelled) const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` for (const n of notifications) { await notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, reason: 'cancelled_by_customer', }) } return session } export const expirePairingRequest = async (sessionId) => { const [session] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} RETURNING id, customer_id, status ` if (!session) return null pairingTimeouts.delete(sessionId) // Mark all pending notifications as ignored await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.IGNORED}, responded_at = NOW() WHERE session_id = ${sessionId} AND response IS NULL ` // Notify customer via WebSocket (FCM fallback) await notifyCustomer(session.customer_id, { type: WsMessage.SESSION_EXPIRED, session_id: sessionId, }) // Notify mitras to dismiss (request expired) const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` for (const n of notifications) { await notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, reason: 'expired', }) } return session } export const getSessionStatus = 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, m.display_name AS mitra_display_name FROM chat_sessions cs LEFT JOIN mitras m ON m.id = cs.mitra_id WHERE cs.id = ${sessionId} ` return session }