Extension request: WS→FCM fallback + chat-recovery on connect

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/<id>. 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 13:24:40 +08:00
parent 368d18a0bf
commit e4bffe1a71
4 changed files with 76 additions and 7 deletions

View File

@@ -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,