From e4bffe1a71960c7bcaa16b20939e9f02d38eb712 Mon Sep 17 00:00:00 2001 From: Ramadhan Sjamsani Date: Thu, 21 May 2026 13:24:40 +0800 Subject: [PATCH] =?UTF-8?q?Extension=20request:=20WS=E2=86=92FCM=20fallbac?= =?UTF-8?q?k=20+=20chat-recovery=20on=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today the customer's "Perpanjang" only reaches the mitra via session- scoped WS. If the mitra is on Home/Undangan, in a different session, or backgrounded, the WS send no-ops and the 10s safeguard timeout fires auto-reject (or auto-approve if the mitra happens to also have an active general WS, depending on config) — either way the mitra never saw the request. Backend: - extension.service.js::requestExtension now falls back to FCM via notification.service when the mitra isn't on the session WS. Mirrors the pairing notifyMitra pattern (Curhat Baru). Customer display name is pulled into the session lookup for the FCM body. - shared.chat.routes.js: /chat/:sessionId/info now returns pending_extension (extension_id, duration_minutes, price, requested_at, expires_at, timeout_seconds) so the chat screen can rehydrate the accept/reject UI after a cold-start FCM tap. expires_at is derived from requested_at + extension_timeout_seconds config. Mitra app: - mitra_chat_notifier.dart::connect parses pending_extension from /info and seeds MitraChatConnectedData.extensionRequest — the existing _buildExtensionView renders unchanged. - notification_service.dart::_navigateFromMessage handles type=extension_request → pushes /chat/session/. Composes with the new /info pending_extension to bring the mitra straight into the accept/reject view. Verified end-to-end on dev backend (FCM call returned sent=true; /info returns pending_extension when within timeout window). Visual delivery on emulator-5556 deferred — API 24 AVD queues FCM 5-30 min per feedback-emulator-avd-versions. Out of scope (follow-ups): - Customer-side FCM for EXTENSION_RESPONSE (accepted/rejected/timeout) - Perpanjang tab list endpoint + Flutter provider + UI Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/routes/public/shared.chat.routes.js | 30 +++++++++++++- backend/src/services/extension.service.js | 39 ++++++++++++++++--- .../lib/core/chat/mitra_chat_notifier.dart | 8 ++++ .../notifications/notification_service.dart | 6 +++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/backend/src/routes/public/shared.chat.routes.js b/backend/src/routes/public/shared.chat.routes.js index c7786a9..0a5de81 100644 --- a/backend/src/routes/public/shared.chat.routes.js +++ b/backend/src/routes/public/shared.chat.routes.js @@ -56,7 +56,35 @@ export const sharedChatRoutes = async (app) => { return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } }) } const goodbyeSubmittedByMe = await hasUserSubmittedClosure(sessionId, request.userType) - return reply.send({ success: true, data: { ...session, goodbye_submitted_by_me: goodbyeSubmittedByMe } }) + // Surface any pending extension so the mitra chat screen can recover the + // _buildExtensionView state after a cold-start via FCM tap — without this, + // the WS EXTENSION_REQUEST frame fired earlier has nothing to bind to. + const [pendingExt] = await sql` + SELECT id, requested_duration_minutes, requested_price, requested_at + FROM session_extensions + WHERE session_id = ${sessionId} AND status = 'pending' + ORDER BY requested_at DESC + LIMIT 1 + ` + let pending_extension = null + if (pendingExt) { + const { getExtensionTimeoutConfig } = await import('../../services/config.service.js') + const { extension_timeout_seconds } = await getExtensionTimeoutConfig() + const requestedAtMs = new Date(pendingExt.requested_at).getTime() + const expiresAtMs = requestedAtMs + extension_timeout_seconds * 1000 + pending_extension = { + extension_id: pendingExt.id, + duration_minutes: pendingExt.requested_duration_minutes, + price: pendingExt.requested_price, + requested_at: pendingExt.requested_at, + expires_at: new Date(expiresAtMs).toISOString(), + timeout_seconds: extension_timeout_seconds, + } + } + return reply.send({ + success: true, + data: { ...session, goodbye_submitted_by_me: goodbyeSubmittedByMe, pending_extension }, + }) }) // Get full transcript (read-only, for history) diff --git a/backend/src/services/extension.service.js b/backend/src/services/extension.service.js index 10b6aec..d84e1b8 100644 --- a/backend/src/services/extension.service.js +++ b/backend/src/services/extension.service.js @@ -3,6 +3,7 @@ import { sendToSessionParticipant, isUserOnlineWs } from '../plugins/websocket.j import { extendSessionTimer, clearClosureGraceTimer, startClosureGraceTimer } from './session-timer.service.js' import { isMitraReachable } from './mitra-status.service.js' import { consumePaymentSession, failPaymentSession, getPaymentSession } from './payment.service.js' +import { sendPushNotification } from './notification.service.js' import { getExtensionTimeoutConfig, getExtensionDefaultActionOnTimeout, @@ -48,11 +49,16 @@ const getExtensionTimeoutAction = async () => { * (mitra explicit accept OR auto-approve fires). */ export const requestExtension = async (sessionId, customerId, { duration_minutes, price, extension_payment_session_id }) => { - // Verify session belongs to customer and is in an extendable state + // Verify session belongs to customer and is in an extendable state. + // customer_display_name is pulled along for the FCM body when the mitra + // misses the WS frame. const [session] = await sql` - SELECT id, customer_id, mitra_id, status, topic_sensitivity FROM chat_sessions - WHERE id = ${sessionId} AND customer_id = ${customerId} - AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.CLOSING}) + SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, + c.display_name AS customer_display_name + FROM chat_sessions cs + INNER JOIN customers c ON c.id = cs.customer_id + WHERE cs.id = ${sessionId} AND cs.customer_id = ${customerId} + AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.CLOSING}) ` if (!session) { throw Object.assign(new Error('Session not found or already ended'), { code: 'SESSION_NOT_ACTIVE', statusCode: 409 }) @@ -103,8 +109,13 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes const timeoutMs = await getExtensionTimeoutMs() const timeoutSeconds = Math.round(timeoutMs / 1000) - // Notify mitra — include current topic sensitivity so UI can highlight - sendToSessionParticipant(sessionId, UserType.MITRA, { + // Notify mitra — include current topic sensitivity so UI can highlight. + // If the mitra isn't on this session's chat WS (on Home/Undangan, in + // another chat, or app backgrounded), fall back to FCM. The session- + // scoped WS is the only channel that reaches the in-chat `_buildExtensionView` + // in real time; FCM gets them to /chat/session/:id, where chat connect + // restores the pending extension state via /chat/:sessionId/info. + const wsSent = sendToSessionParticipant(sessionId, UserType.MITRA, { type: WsMessage.EXTENSION_REQUEST, extension_id: extension.id, session_id: sessionId, @@ -114,6 +125,22 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes timeout_seconds: timeoutSeconds, }) + if (!wsSent) { + await sendPushNotification(UserType.MITRA, session.mitra_id, { + title: 'Permintaan Perpanjang', + body: `${session.customer_display_name} mau lanjut +${duration_minutes} menit`, + data: { + type: WsMessage.EXTENSION_REQUEST, + session_id: sessionId, + extension_id: extension.id, + duration_minutes, + price, + timeout_seconds: timeoutSeconds, + action: 'open_extension', + }, + }) + } + // Notify customer that chat is paused sendToSessionParticipant(sessionId, UserType.CUSTOMER, { type: WsMessage.SESSION_PAUSED, diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart index 2afe835..8cf5e7d 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -179,6 +179,13 @@ class MitraChat extends _$MitraChat { ? rawTopics.whereType().toList(growable: false) : const []; + // If the customer requested an extension while we were off-WS (e.g. + // mitra on Home, app backgrounded, FCM tap cold-started us here), + // `/info` carries the pending row so we can paint `_buildExtensionView` + // immediately. Without this, the in-chat extension flow would only + // recover if the customer happened to request again after we connect. + final pendingExt = sessionData?['pending_extension'] as Map?; + final response = await _apiClient.get('/api/shared/chat/$sessionId/messages'); final messagesData = response['data'] as List; final messages = messagesData.map((m) => MitraChatMessage( @@ -222,6 +229,7 @@ class MitraChat extends _$MitraChat { topicSensitivity: sessionTopic, topics: espTopics, mode: sessionMode, + extensionRequest: pendingExt, ); } catch (e) { state = const MitraChatErrorData('Gagal terhubung ke chat.'); diff --git a/mitra_app/lib/core/notifications/notification_service.dart b/mitra_app/lib/core/notifications/notification_service.dart index 227c3c9..d0640f0 100644 --- a/mitra_app/lib/core/notifications/notification_service.dart +++ b/mitra_app/lib/core/notifications/notification_service.dart @@ -127,6 +127,12 @@ class NotificationService { // Update the notifier state with this session, then navigate onChatRequestTapped?.call(sessionId); _router!.go('/home'); + } else if (type == 'extension_request' && sessionId != null) { + // Customer requested an extension while mitra was off-WS (Home tab, + // backgrounded, app killed). Push to the chat — mitra_chat_notifier + // pulls the pending row from /chat/:sessionId/info and renders + // _buildExtensionView with the accept/reject CTAs. + _router!.push('/chat/session/$sessionId', extra: {'customerName': 'Customer'}); } else if (type == 'session_closing' || type == 'session_expired') { // Navigate to the chat session closure screen if (sessionId != null) {