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:
2026-04-09 14:22:41 +08:00
parent fa8c963d92
commit ed765d230c
11 changed files with 187 additions and 17 deletions

View File

@@ -274,6 +274,20 @@ const migrate = async () => {
ON CONFLICT (key) DO NOTHING 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.') console.log('Migration complete.')
await sql.end() await sql.end()
} }

View File

@@ -6,6 +6,7 @@ import {
getFreeTrialConfig, setFreeTrialConfig, getFreeTrialConfig, setFreeTrialConfig,
getExtensionTimeoutConfig, setExtensionTimeoutConfig, getExtensionTimeoutConfig, setExtensionTimeoutConfig,
getEarlyEndConfig, setEarlyEndConfig, getEarlyEndConfig, setEarlyEndConfig,
getMitraPingConfig, setMitraPingConfig,
} from '../../services/config.service.js' } from '../../services/config.service.js'
const attachCcUser = async (request, reply) => { const attachCcUser = async (request, reply) => {
@@ -102,6 +103,28 @@ export const internalConfigRoutes = async (app) => {
return reply.send({ success: true, data: config }) 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 --- // --- Price Tiers ---
app.get('/price-tiers', { app.get('/price-tiers', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],

View File

@@ -1,7 +1,7 @@
import { authenticate } from '../../plugins/auth.js' import { authenticate } from '../../plugins/auth.js'
import { getCustomerByFirebaseUid } from '../../services/customer.service.js' import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.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 { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
import { requestExtension } from '../../services/extension.service.js' import { requestExtension } from '../../services/extension.service.js'
import { EndedBy } from '../../constants.js' import { EndedBy } from '../../constants.js'
@@ -73,6 +73,11 @@ export const clientChatRoutes = async (app) => {
return reply.send({ success: true, data: session ?? null }) 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) => { app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await endSession(request.params.sessionId, EndedBy.CUSTOMER, request.customer.id) const session = await endSession(request.params.sessionId, EndedBy.CUSTOMER, request.customer.id)
return reply.send({ success: true, data: session }) return reply.send({ success: true, data: session })

View File

@@ -1,7 +1,7 @@
import { authenticate } from '../../plugins/auth.js' import { authenticate } from '../../plugins/auth.js'
import { getMitraByFirebaseUid } from '../../services/mitra.service.js' import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.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 { respondToExtension } from '../../services/extension.service.js'
import { EndedBy } from '../../constants.js' import { EndedBy } from '../../constants.js'
@@ -38,6 +38,11 @@ export const mitraChatRoutes = async (app) => {
return reply.send({ success: true, data: sessions }) 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) => { app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const session = await endSession(request.params.sessionId, EndedBy.MITRA, request.mitra.id) const session = await endSession(request.params.sessionId, EndedBy.MITRA, request.mitra.id)
return reply.send({ success: true, data: session }) return reply.send({ success: true, data: session })

View File

@@ -26,7 +26,7 @@ const start = async () => {
// Auto-offline mitras with stale heartbeat (every 30s) // Auto-offline mitras with stale heartbeat (every 30s)
setInterval(async () => { setInterval(async () => {
try { try {
const count = await autoOfflineStaleMitras(45) const count = await autoOfflineStaleMitras()
if (count > 0) console.log(`Auto-offlined ${count} stale mitra(s)`) if (count > 0) console.log(`Auto-offlined ${count} stale mitra(s)`)
} catch (err) { } catch (err) {
console.error('Auto-offline check failed:', err) console.error('Auto-offline check failed:', err)

View File

@@ -2,6 +2,7 @@ import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js' import { publish } from '../plugins/valkey.js'
import { clearSessionTimer, clearClosureGraceTimer } from './session-timer.service.js' import { clearSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
import { sendToSessionParticipant } from '../plugins/websocket.js' import { sendToSessionParticipant } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js'
import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js' import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js'
const sql = getDb() const sql = getDb()
@@ -53,10 +54,25 @@ export const completeSession = async (sessionId) => {
` `
if (!session) return null 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 } const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data) const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
sendToSessionParticipant(sessionId, UserType.MITRA, 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 }) await publish(`session:${sessionId}:status`, { type: WsMessage.SESSION_ENDED, session_id: sessionId })
@@ -90,10 +106,25 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
clearSessionTimer(sessionId) 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 } const data = { type: WsMessage.SESSION_CLOSING, session_id: sessionId, ended_by: userType }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data) const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
sendToSessionParticipant(sessionId, UserType.MITRA, 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 return session
} }

View File

@@ -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 }) => { export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => {
if (mitra_enabled !== undefined) { if (mitra_enabled !== undefined) {
await sql` await sql`

View File

@@ -1,5 +1,6 @@
import { getDb } from '../db/client.js' import { getDb } from '../db/client.js'
import { SessionStatus } from '../constants.js' import { SessionStatus } from '../constants.js'
import { getMitraPingConfig } from './config.service.js'
const sql = getDb() const sql = getDb()
@@ -58,7 +59,12 @@ export const getStatus = async (mitraId) => {
FROM mitra_online_status FROM mitra_online_status
WHERE mitra_id = ${mitraId} 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 () => { export const getOnlineMitras = async () => {
@@ -89,7 +95,13 @@ export const getOnlineLogs = async (mitraId, { page = 1, limit = 50 } = {}) => {
return { items, total: Number(count), page, limit } 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` const stale = await sql`
UPDATE mitra_online_status UPDATE mitra_online_status
SET is_online = false, last_offline_at = NOW(), updated_at = NOW() SET is_online = false, last_offline_at = NOW(), updated_at = NOW()

View File

@@ -19,8 +19,8 @@ const notifyMitra = async (mitraId, data) => {
if (data.type === WsMessage.CHAT_REQUEST) { if (data.type === WsMessage.CHAT_REQUEST) {
await sendPushNotification(UserType.MITRA, mitraId, { await sendPushNotification(UserType.MITRA, mitraId, {
title: 'Permintaan Chat Baru', title: 'Permintaan Chat Baru',
body: 'Ada pelanggan yang ingin curhat!', body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.',
data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id }, data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id, action: 'open_accept' },
}) })
} }
} }

View File

@@ -1,6 +1,7 @@
import { getDb } from '../db/client.js' import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js' import { publish } from '../plugins/valkey.js'
import { sendToSessionParticipant } from '../plugins/websocket.js' import { sendToSessionParticipant } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js'
import { UserType, SessionStatus, WsMessage } from '../constants.js' import { UserType, SessionStatus, WsMessage } from '../constants.js'
const sql = getDb() const sql = getDb()
@@ -85,15 +86,29 @@ const onSessionExpired = async (sessionId) => {
` `
if (!session) return 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 } 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) // 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, { sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.SESSION_CLOSING, session_id: sessionId, 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 // Also publish via Valkey for any listeners
await publish(`session:${sessionId}:status`, expiredData) await publish(`session:${sessionId}:status`, expiredData)

View File

@@ -1,6 +1,6 @@
import { getDb } from '../db/client.js' import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.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() const sql = getDb()
@@ -155,6 +155,42 @@ export const getSessionById = async (sessionId) => {
return session 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 } = {}) => { export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit const offset = (page - 1) * limit
const items = await sql` const items = await sql`