Files
halobestie-clone/backend/src/services/pairing.service.js
ramadhan sjamsani 4c6130aa04 Phase 3.2 WS2: Mitra request activity log + control center page
- DB migration: add active_session_count column + mitra_notified index
- Constants: add MISSED to NotificationResponse
- Pairing service: record active_session_count on notification creation,
  use MISSED (not IGNORED) when another mitra accepts first
- New mitra-activity.service.js: getMitraActivityLog (paginated),
  getMitraActivitySummary (per-mitra aggregates with acceptance rate)
- New mitra-activity.routes.js: GET /internal/mitra-activity/log,
  GET /internal/mitra-activity/summary
- Control center: new MitraActivityPage with summary table + detail log,
  filters (mitra, date range), color-coded response types, pagination
- Register route in App.jsx, add "Aktivitas Mitra" nav link in Layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:20:52 +08:00

320 lines
11 KiB
JavaScript

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
}