From ed765d230ca8a8db38a68b7283d01a0b81b474b8 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Thu, 9 Apr 2026 14:22:41 +0800 Subject: [PATCH] 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) --- backend/src/db/migrate.js | 14 ++++++ backend/src/routes/internal/config.routes.js | 23 ++++++++++ .../src/routes/public/client.chat.routes.js | 7 ++- .../src/routes/public/mitra.chat.routes.js | 7 ++- backend/src/server.js | 2 +- backend/src/services/closure.service.js | 43 ++++++++++++++++--- backend/src/services/config.service.js | 29 +++++++++++++ backend/src/services/mitra-status.service.js | 16 ++++++- backend/src/services/pairing.service.js | 4 +- backend/src/services/session-timer.service.js | 21 +++++++-- backend/src/services/session.service.js | 38 +++++++++++++++- 11 files changed, 187 insertions(+), 17 deletions(-) diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index d448caf..9ecb72c 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -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() } diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index 87f726d..dfe9245 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -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')], diff --git a/backend/src/routes/public/client.chat.routes.js b/backend/src/routes/public/client.chat.routes.js index 39922cb..d18649f 100644 --- a/backend/src/routes/public/client.chat.routes.js +++ b/backend/src/routes/public/client.chat.routes.js @@ -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 }) diff --git a/backend/src/routes/public/mitra.chat.routes.js b/backend/src/routes/public/mitra.chat.routes.js index 347b3ca..40ba302 100644 --- a/backend/src/routes/public/mitra.chat.routes.js +++ b/backend/src/routes/public/mitra.chat.routes.js @@ -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 }) diff --git a/backend/src/server.js b/backend/src/server.js index 790e653..62e1880 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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) diff --git a/backend/src/services/closure.service.js b/backend/src/services/closure.service.js index 814bc70..9c70497 100644 --- a/backend/src/services/closure.service.js +++ b/backend/src/services/closure.service.js @@ -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 } diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index aaad759..1b253af 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -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` diff --git a/backend/src/services/mitra-status.service.js b/backend/src/services/mitra-status.service.js index 1c0e845..abd1991 100644 --- a/backend/src/services/mitra-status.service.js +++ b/backend/src/services/mitra-status.service.js @@ -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() diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index 27d593f..e4076dd 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -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' }, }) } } diff --git a/backend/src/services/session-timer.service.js b/backend/src/services/session-timer.service.js index 2799332..4db40f8 100644 --- a/backend/src/services/session-timer.service.js +++ b/backend/src/services/session-timer.service.js @@ -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) diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js index d08e61a..2d37ce3 100644 --- a/backend/src/services/session.service.js +++ b/backend/src/services/session.service.js @@ -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`