- Add require_mitra_ping + mitra_ping_interval_seconds config keys (migration) - Add getMitraPingConfig/setMitraPingConfig to config service - Add GET/PATCH /internal/config/mitra-ping routes for control center - Update mitra status service: honor ping config in auto-offline sweep, include ping config in GET /api/mitra/status response - Enhance pairing FCM payload with action: 'open_accept' for deep-link - Add FCM fallback to closure.service (initiateEarlyEnd, completeSession) - Add FCM fallback to session-timer.service (onSessionExpired) - Add unread count queries (getActiveSessionByCustomerWithUnread, getActiveSessionsByMitraWithUnread) - Add GET /api/client/chat/session/active-with-unread route - Add GET /api/mitra/chat-requests/sessions/active-with-unread route Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
140 lines
5.0 KiB
JavaScript
140 lines
5.0 KiB
JavaScript
import { getDb } from '../db/client.js'
|
|
import { publish } from '../plugins/valkey.js'
|
|
import { clearSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
|
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
|
import { sendPushNotification } from './notification.service.js'
|
|
import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.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 (${SessionStatus.CLOSING}, ${SessionStatus.ACTIVE}, ${SessionStatus.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 === UserType.CUSTOMER)
|
|
const hasMitra = closures.some((c) => c.user_type === UserType.MITRA)
|
|
|
|
if (hasCustomer && hasMitra) {
|
|
// Both submitted — complete the session
|
|
await completeSession(sessionId)
|
|
}
|
|
|
|
return closure
|
|
}
|
|
|
|
export const completeSession = async (sessionId) => {
|
|
clearSessionTimer(sessionId)
|
|
clearClosureGraceTimer(sessionId)
|
|
|
|
const [session] = await sql`
|
|
UPDATE chat_sessions
|
|
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, FCM fallback if WebSocket is down
|
|
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
|
|
const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
|
const mitraSent = sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
|
|
|
if (!customerSent) {
|
|
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
|
|
title: 'Sesi Selesai',
|
|
body: 'Sesi curhat kamu telah selesai.',
|
|
data: { type: WsMessage.SESSION_COMPLETED, session_id: sessionId },
|
|
})
|
|
}
|
|
if (!mitraSent) {
|
|
await sendPushNotification(UserType.MITRA, session.mitra_id, {
|
|
title: 'Sesi Selesai',
|
|
body: 'Sesi curhat telah selesai.',
|
|
data: { type: WsMessage.SESSION_COMPLETED, 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 === 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 = ${SessionStatus.CLOSING}, ended_by = ${userType}
|
|
WHERE id = ${sessionId} AND status = ${SessionStatus.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, FCM fallback if WebSocket is down
|
|
const data = { type: WsMessage.SESSION_CLOSING, session_id: sessionId, ended_by: userType }
|
|
const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
|
const mitraSent = sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
|
|
|
if (!customerSent) {
|
|
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
|
|
title: 'Sesi Berakhir',
|
|
body: 'Sesi curhat kamu telah berakhir. Ketuk untuk menulis pesan penutup.',
|
|
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
|
|
})
|
|
}
|
|
if (!mitraSent) {
|
|
await sendPushNotification(UserType.MITRA, session.mitra_id, {
|
|
title: 'Sesi Berakhir',
|
|
body: 'Sesi curhat telah berakhir. Ketuk untuk menulis pesan penutup.',
|
|
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
|
|
})
|
|
}
|
|
|
|
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
|
|
`
|
|
}
|