- 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>
320 lines
11 KiB
JavaScript
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
|
|
}
|