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) {