Files
halobestie-clone/backend/src/services/extension.service.js
Ramadhan Sjamsani e4bffe1a71 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>
2026-05-21 13:24:40 +08:00

349 lines
14 KiB
JavaScript

import { getDb } from '../db/client.js'
import { sendToSessionParticipant, isUserOnlineWs } from '../plugins/websocket.js'
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,
} from './config.service.js'
import {
UserType,
SessionStatus,
ExtensionStatus,
TransactionType,
WsMessage,
PaymentSessionStatus,
ExtensionTimeoutAction,
PairingFailureCause,
} from '../constants.js'
const sql = getDb()
// Extension timeout map: extensionId → timeoutId
const extensionTimeouts = new Map()
const getExtensionTimeoutMs = async () => {
const { extension_timeout_seconds } = await getExtensionTimeoutConfig()
return extension_timeout_seconds * 1000
}
const getExtensionTimeoutAction = async () => {
const { extension_default_action_on_timeout } = await getExtensionDefaultActionOnTimeout()
return Object.values(ExtensionTimeoutAction).includes(extension_default_action_on_timeout)
? extension_default_action_on_timeout
: ExtensionTimeoutAction.AUTO_APPROVE
}
/**
* Customer requests an extension.
*
* `extension_payment_session_id` is REQUIRED. The payment session must:
* - belong to this customer
* - be in `confirmed` status (not yet consumed)
* - have `is_extension = true`
* - have `is_first_session_discount = false` (extensions never use the first-session discount)
*
* The payment session is NOT consumed at request time. It is consumed at approval moment
* (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.
// customer_display_name is pulled along for the FCM body when the mitra
// misses the WS frame.
const [session] = await sql`
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 })
}
// Validate extension payment session
if (!extension_payment_session_id) {
throw Object.assign(new Error('extension_payment_session_id is required'), {
code: 'VALIDATION_ERROR', statusCode: 422,
})
}
const paySession = await getPaymentSession(extension_payment_session_id)
if (!paySession) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (paySession.customer_id !== customerId) {
throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403,
})
}
if (paySession.status !== PaymentSessionStatus.CONFIRMED) {
throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), {
code: 'INVALID_STATE', statusCode: 409,
})
}
if (!paySession.is_extension) {
throw Object.assign(new Error('Payment session is not flagged as an extension payment'), {
code: 'INVALID_STATE', statusCode: 409,
})
}
if (paySession.is_first_session_discount) {
throw Object.assign(new Error('First-session discount is not available for extensions'), {
code: 'FIRST_SESSION_DISCOUNT_NOT_ALLOWED', statusCode: 400,
})
}
// Create extension record (linked to its payment session)
const [extension] = await sql`
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_session_id)
VALUES (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING}, ${extension_payment_session_id})
RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at, payment_session_id
`
// Pause the session
await sql`UPDATE chat_sessions SET status = ${SessionStatus.EXTENDING} WHERE id = ${sessionId}`
// Resolve timeout once so we can both surface it in the WS payload and start the server-side timer.
const timeoutMs = await getExtensionTimeoutMs()
const timeoutSeconds = Math.round(timeoutMs / 1000)
// 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,
duration_minutes,
price,
topic_sensitivity: session.topic_sensitivity,
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,
session_id: sessionId,
reason: 'extension_pending',
})
const timeoutId = setTimeout(async () => {
try {
await timeoutExtension(extension.id, sessionId, session.mitra_id)
} catch (err) {
console.error('timeoutExtension failed', { extensionId: extension.id, sessionId, err })
}
}, timeoutMs)
extensionTimeouts.set(extension.id, timeoutId)
return extension
}
export const respondToExtension = async (extensionId, sessionId, mitraId, accepted) => {
// Verify session belongs to this mitra
const [session] = await sql`
SELECT id FROM chat_sessions WHERE id = ${sessionId} AND mitra_id = ${mitraId}
`
if (!session) {
throw Object.assign(new Error('Session not found'), { code: 'FORBIDDEN', statusCode: 403 })
}
return finalizeExtension(extensionId, sessionId, accepted, /* viaTimeout */ false)
}
/**
* Internal: applies the accepted/rejected outcome. Used by both explicit response
* and the data-driven timeout path.
*/
const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) => {
const status = accepted ? ExtensionStatus.ACCEPTED : ExtensionStatus.REJECTED
const [extension] = await sql`
UPDATE session_extensions
SET status = ${status}, responded_at = NOW()
WHERE id = ${extensionId} AND session_id = ${sessionId} AND status = ${ExtensionStatus.PENDING}
RETURNING id, session_id, requested_duration_minutes, requested_price, status, payment_session_id
`
if (!extension) {
if (viaTimeout) return null // race: already resolved before timer fired
throw Object.assign(new Error('Extension not found or already resolved'), {
code: 'EXTENSION_RESOLVED', statusCode: 409,
})
}
// Clear timeout
const timeoutId = extensionTimeouts.get(extensionId)
if (timeoutId) {
clearTimeout(timeoutId)
extensionTimeouts.delete(extensionId)
}
if (accepted) {
// Charge fires AT approval moment (explicit OR auto-approve).
if (extension.payment_session_id) {
await consumePaymentSession(extension.payment_session_id)
}
// Clear any pending grace timer from the previous expiry
clearClosureGraceTimer(sessionId)
// Extend the session
const extended = await extendSessionTimer(extension.session_id, extension.requested_duration_minutes)
// Resume session
await sql`UPDATE chat_sessions SET status = ${SessionStatus.ACTIVE} WHERE id = ${extension.session_id}`
// Record transaction
await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
SELECT customer_id, id, ${TransactionType.EXTENSION}, ${extension.requested_price}
FROM chat_sessions WHERE id = ${extension.session_id}
`
// Notify both parties. Include the freshly-extended `expires_at` so the
// customer's local seconds-left ticker can resume immediately — without it,
// the client has to wait until the next 60s SESSION_TIMER ping to pick up
// the new deadline, leaving the floating expired banner stuck on-screen.
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.EXTENSION_RESPONSE,
accepted: true,
duration_minutes: extension.requested_duration_minutes,
expires_at: extended?.expires_at ?? null,
via_timeout: viaTimeout,
})
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_RESUMED,
session_id: sessionId,
})
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.SESSION_RESUMED,
session_id: sessionId,
})
} else {
// Rejected — no charge. Fail the extension payment session if present.
// viaTimeout=false here means an explicit mitra reject (the timer path goes through
// timeoutExtension which never enters this branch with viaTimeout=true for reject).
if (extension.payment_session_id) {
await failPaymentSession(extension.payment_session_id, PairingFailureCause.EXTENSION_REJECTED)
}
await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}`
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.EXTENSION_RESPONSE,
accepted: false,
via_timeout: viaTimeout,
})
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.SESSION_CLOSING,
session_id: sessionId,
})
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_CLOSING,
session_id: sessionId,
})
startClosureGraceTimer(sessionId)
}
return extension
}
/**
* Data-driven timeout handler.
*
* - read `extension_default_action_on_timeout` config:
* - 'auto_approve': check mitra reachability (WS + Valkey online). If both OK → approve.
* If either is offline/disconnected → fall back to reject (no charge).
* - 'auto_reject' (back-compat flag): reject regardless.
*/
const timeoutExtension = async (extensionId, sessionId, mitraId) => {
extensionTimeouts.delete(extensionId)
// Confirm extension is still pending (race with explicit response)
const [pending] = await sql`
SELECT id FROM session_extensions
WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING}
`
if (!pending) return
const action = await getExtensionTimeoutAction()
// Track WHY we ended up rejecting so the failed-pairings audit row gets the right tag.
// Default: configured policy is auto_reject → use EXTENSION_REJECTED.
let causeTag = PairingFailureCause.EXTENSION_REJECTED
let reasonForClient = 'timeout'
if (action === ExtensionTimeoutAction.AUTO_APPROVE) {
// Safeguard: mitra must be reachable (online in Valkey AND connected via WS).
// Never use "in-session" as a proxy for "online".
const wsConnected = isUserOnlineWs(UserType.MITRA, mitraId)
const onlineFlag = await isMitraReachable(mitraId)
if (wsConnected && onlineFlag) {
// Approve via the same path as explicit accept.
await finalizeExtension(extensionId, sessionId, /* accepted */ true, /* viaTimeout */ true)
return
}
// Safeguard tripped — treat as auto-reject (no charge), but tag the audit row distinctly
// so CC operators can see this was a system-safety decision, not a mitra reject or a
// configured auto-reject policy decision.
causeTag = PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED
reasonForClient = 'safeguard'
}
// auto_reject (configured) OR auto_approve-with-safeguard-tripped — both end with
// the extension marked TIMEOUT, no charge, session moves to CLOSING. The cause_tag
// distinguishes them in the failed-pairings audit log. RETURNING guards against a race
// with explicit accept/decline that landed between the pending check above and here —
// if no row was matched, the extension is no longer ours to time out.
const [timedOut] = await sql`
UPDATE session_extensions
SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW()
WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING}
RETURNING id, payment_session_id
`
if (!timedOut) return
if (timedOut.payment_session_id) {
await failPaymentSession(timedOut.payment_session_id, causeTag)
}
// Move session to closing & notify both parties (matches the explicit-reject UX).
await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${sessionId}`
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.EXTENSION_RESPONSE,
accepted: false,
reason: reasonForClient,
})
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.SESSION_CLOSING,
session_id: sessionId,
})
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_CLOSING,
session_id: sessionId,
})
startClosureGraceTimer(sessionId)
}