Phase 3.1 WS2: Backend FCM fallback, ping config, unread API
- 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>
This commit is contained in:
@@ -274,6 +274,20 @@ const migrate = async () => {
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
// --- Phase 3.1: Mitra Ping Config ---
|
||||
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value)
|
||||
VALUES ('require_mitra_ping', '{"value": true}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value)
|
||||
VALUES ('mitra_ping_interval_seconds', '{"value": 15}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
console.log('Migration complete.')
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getFreeTrialConfig, setFreeTrialConfig,
|
||||
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
|
||||
getEarlyEndConfig, setEarlyEndConfig,
|
||||
getMitraPingConfig, setMitraPingConfig,
|
||||
} from '../../services/config.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
@@ -102,6 +103,28 @@ export const internalConfigRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 3.1: Mitra Ping Config ---
|
||||
app.get('/mitra-ping', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const config = await getMitraPingConfig()
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
app.patch('/mitra-ping', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { require_ping, ping_interval_seconds } = request.body ?? {}
|
||||
if (require_ping !== undefined && typeof require_ping !== 'boolean') {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'require_ping must be a boolean' } })
|
||||
}
|
||||
if (ping_interval_seconds !== undefined && (typeof ping_interval_seconds !== 'number' || ping_interval_seconds < 5)) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'ping_interval_seconds must be a number >= 5' } })
|
||||
}
|
||||
const config = await setMitraPingConfig({ require_ping, ping_interval_seconds })
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Price Tiers ---
|
||||
app.get('/price-tiers', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
|
||||
import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js'
|
||||
import { getActiveSessionByCustomer, endSession, getCustomerHistory } from '../../services/session.service.js'
|
||||
import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory } from '../../services/session.service.js'
|
||||
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
|
||||
import { requestExtension } from '../../services/extension.service.js'
|
||||
import { EndedBy } from '../../constants.js'
|
||||
@@ -73,6 +73,11 @@ export const clientChatRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: session ?? null })
|
||||
})
|
||||
|
||||
app.get('/session/active-with-unread', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const session = await getActiveSessionByCustomerWithUnread(request.customer.id)
|
||||
return reply.send({ success: true, data: session ?? null })
|
||||
})
|
||||
|
||||
app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const session = await endSession(request.params.sessionId, EndedBy.CUSTOMER, request.customer.id)
|
||||
return reply.send({ success: true, data: session })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
||||
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js'
|
||||
import { getActiveSessionsByMitra, endSession, getMitraHistory } from '../../services/session.service.js'
|
||||
import { getActiveSessionsByMitra, getActiveSessionsByMitraWithUnread, endSession, getMitraHistory } from '../../services/session.service.js'
|
||||
import { respondToExtension } from '../../services/extension.service.js'
|
||||
import { EndedBy } from '../../constants.js'
|
||||
|
||||
@@ -38,6 +38,11 @@ export const mitraChatRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: sessions })
|
||||
})
|
||||
|
||||
app.get('/sessions/active-with-unread', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
const sessions = await getActiveSessionsByMitraWithUnread(request.mitra.id)
|
||||
return reply.send({ success: true, data: sessions })
|
||||
})
|
||||
|
||||
app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||
const session = await endSession(request.params.sessionId, EndedBy.MITRA, request.mitra.id)
|
||||
return reply.send({ success: true, data: session })
|
||||
|
||||
@@ -26,7 +26,7 @@ const start = async () => {
|
||||
// Auto-offline mitras with stale heartbeat (every 30s)
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const count = await autoOfflineStaleMitras(45)
|
||||
const count = await autoOfflineStaleMitras()
|
||||
if (count > 0) console.log(`Auto-offlined ${count} stale mitra(s)`)
|
||||
} catch (err) {
|
||||
console.error('Auto-offline check failed:', err)
|
||||
|
||||
@@ -2,6 +2,7 @@ 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()
|
||||
@@ -53,10 +54,25 @@ export const completeSession = async (sessionId) => {
|
||||
`
|
||||
if (!session) return null
|
||||
|
||||
// Notify both parties
|
||||
// Notify both parties, FCM fallback if WebSocket is down
|
||||
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
|
||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||
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 })
|
||||
|
||||
@@ -90,10 +106,25 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
|
||||
|
||||
clearSessionTimer(sessionId)
|
||||
|
||||
// Notify both parties to enter closure flow
|
||||
// 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 }
|
||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -82,6 +82,35 @@ export const getEarlyEndConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 3.1: Mitra Ping Config ---
|
||||
|
||||
export const getMitraPingConfig = async () => {
|
||||
const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'`
|
||||
const [intervalRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_ping_interval_seconds'`
|
||||
return {
|
||||
require_ping: requireRow?.value?.value ?? true,
|
||||
ping_interval_seconds: intervalRow?.value?.value ?? 15,
|
||||
}
|
||||
}
|
||||
|
||||
export const setMitraPingConfig = async ({ require_ping, ping_interval_seconds }) => {
|
||||
if (require_ping !== undefined) {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('require_mitra_ping', ${sql.json({ value: require_ping })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
if (ping_interval_seconds !== undefined) {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('mitra_ping_interval_seconds', ${sql.json({ value: ping_interval_seconds })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
return getMitraPingConfig()
|
||||
}
|
||||
|
||||
export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => {
|
||||
if (mitra_enabled !== undefined) {
|
||||
await sql`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { SessionStatus } from '../constants.js'
|
||||
import { getMitraPingConfig } from './config.service.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
@@ -58,7 +59,12 @@ export const getStatus = async (mitraId) => {
|
||||
FROM mitra_online_status
|
||||
WHERE mitra_id = ${mitraId}
|
||||
`
|
||||
return status
|
||||
const pingConfig = await getMitraPingConfig()
|
||||
return {
|
||||
...status,
|
||||
require_ping: pingConfig.require_ping,
|
||||
ping_interval_seconds: pingConfig.ping_interval_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
export const getOnlineMitras = async () => {
|
||||
@@ -89,7 +95,13 @@ export const getOnlineLogs = async (mitraId, { page = 1, limit = 50 } = {}) => {
|
||||
return { items, total: Number(count), page, limit }
|
||||
}
|
||||
|
||||
export const autoOfflineStaleMitras = async (staleSeconds = 45) => {
|
||||
export const autoOfflineStaleMitras = async () => {
|
||||
const pingConfig = await getMitraPingConfig()
|
||||
|
||||
// If ping is not required, skip the auto-offline sweep entirely
|
||||
if (!pingConfig.require_ping) return 0
|
||||
|
||||
const staleSeconds = pingConfig.ping_interval_seconds * 3
|
||||
const stale = await sql`
|
||||
UPDATE mitra_online_status
|
||||
SET is_online = false, last_offline_at = NOW(), updated_at = NOW()
|
||||
|
||||
@@ -19,8 +19,8 @@ const notifyMitra = async (mitraId, data) => {
|
||||
if (data.type === WsMessage.CHAT_REQUEST) {
|
||||
await sendPushNotification(UserType.MITRA, mitraId, {
|
||||
title: 'Permintaan Chat Baru',
|
||||
body: 'Ada pelanggan yang ingin curhat!',
|
||||
data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id },
|
||||
body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.',
|
||||
data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id, action: 'open_accept' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { publish } from '../plugins/valkey.js'
|
||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||
import { sendPushNotification } from './notification.service.js'
|
||||
import { UserType, SessionStatus, WsMessage } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
@@ -85,15 +86,29 @@ const onSessionExpired = async (sessionId) => {
|
||||
`
|
||||
if (!session) return
|
||||
|
||||
// Notify customer — sees extend/close dialog
|
||||
// Notify customer — sees extend/close dialog; FCM fallback if WebSocket is down
|
||||
const expiredData = { type: WsMessage.SESSION_EXPIRED, session_id: sessionId }
|
||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, expiredData)
|
||||
const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, expiredData)
|
||||
if (!customerSent) {
|
||||
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
|
||||
title: 'Waktu Sesi Habis',
|
||||
body: 'Sesi curhat kamu telah habis. Ketuk untuk memperpanjang atau mengakhiri.',
|
||||
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
|
||||
})
|
||||
}
|
||||
|
||||
// Notify mitra — sees expired + closing (waits for customer's decision or goodbye)
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, expiredData)
|
||||
const mitraSent = sendToSessionParticipant(sessionId, UserType.MITRA, expiredData)
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, {
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
// Also publish via Valkey for any listeners
|
||||
await publish(`session:${sessionId}:status`, expiredData)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { publish } from '../plugins/valkey.js'
|
||||
import { UserType, SessionStatus, WsMessage } from '../constants.js'
|
||||
import { UserType, SessionStatus, MessageStatus, WsMessage } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
@@ -155,6 +155,42 @@ export const getSessionById = async (sessionId) => {
|
||||
return session
|
||||
}
|
||||
|
||||
// --- Phase 3.1: Unread counts ---
|
||||
|
||||
export const getActiveSessionByCustomerWithUnread = 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,
|
||||
(SELECT COUNT(*) FROM chat_messages cm
|
||||
WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.MITRA}
|
||||
AND cm.status IN (${MessageStatus.SENT}, ${MessageStatus.DELIVERED}))::int AS unread_count
|
||||
FROM chat_sessions cs
|
||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||
WHERE cs.customer_id = ${customerId}
|
||||
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING})
|
||||
ORDER BY cs.created_at DESC LIMIT 1
|
||||
`
|
||||
return session
|
||||
}
|
||||
|
||||
export const getActiveSessionsByMitraWithUnread = 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,
|
||||
(SELECT COUNT(*) FROM chat_messages cm
|
||||
WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.CUSTOMER}
|
||||
AND cm.status IN (${MessageStatus.SENT}, ${MessageStatus.DELIVERED}))::int AS unread_count
|
||||
FROM chat_sessions cs
|
||||
INNER JOIN customers c ON c.id = cs.customer_id
|
||||
WHERE cs.mitra_id = ${mitraId}
|
||||
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING})
|
||||
ORDER BY cs.created_at DESC
|
||||
`
|
||||
return sessions
|
||||
}
|
||||
|
||||
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
|
||||
const offset = (page - 1) * limit
|
||||
const items = await sql`
|
||||
|
||||
Reference in New Issue
Block a user