import { getDb } from '../db/client.js' import { getMaxCustomersPerMitra, getPairingBlastTimeoutSeconds, getReturningChatConfirmationTimeoutSeconds } from './config.service.js' import { sendToUser } from '../plugins/websocket.js' import { sendPushNotification } from './notification.service.js' import { startSessionTimer } from './session-timer.service.js' import { startSessionListener } from './chat-handler.service.js' import { consumePaymentSession, failPaymentSession, getPaymentSession, recordIntermediateFailure } from './payment.service.js' import { isMitraReachable, isMitraInActiveSessionWithCustomer, getMitraActiveSessionCount } from './mitra-status.service.js' import { UserType, SessionStatus, NotificationResponse, TransactionType, WsMessage, TopicSensitivity, PaymentSessionStatus, PairingFailureCause, PairingRequestType, } from '../constants.js' const sql = getDb() // Timeout map for active pairing requests (sessionId → timeoutId) const pairingTimeouts = new Map() // Send notification to mitra via WebSocket, fall back to FCM if offline const notifyMitra = async (mitraId, data) => { const sent = sendToUser(UserType.MITRA, mitraId, data) if (!sent) { // Mitra not connected via WebSocket — send FCM push if (data.type === WsMessage.CHAT_REQUEST) { await sendPushNotification(UserType.MITRA, mitraId, { title: 'Permintaan Chat Baru', body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.', data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id, request_type: data.request_type || PairingRequestType.GENERAL, action: 'open_accept', }, }) } } } // Send notification to customer via WebSocket, fall back to FCM if offline const notifyCustomer = async (customerId, data) => { const sent = sendToUser(UserType.CUSTOMER, customerId, data) console.log(`[notifyCustomer] customerId=${customerId} type=${data.type} ws_sent=${sent}`) if (!sent) { if (data.type === WsMessage.PAIRED) { await sendPushNotification(UserType.CUSTOMER, customerId, { title: 'Bestie Ditemukan!', body: `${data.mitra_display_name} siap menemanimu curhat`, data: { type: WsMessage.PAIRED, session_id: data.session_id }, }) } else if (data.type === WsMessage.SESSION_EXPIRED) { await sendPushNotification(UserType.CUSTOMER, customerId, { title: 'Tidak Ada Bestie', body: 'Maaf, tidak ada bestie yang tersedia saat ini.', data: { type: WsMessage.SESSION_EXPIRED, session_id: data.session_id }, }) } else if (data.type === WsMessage.PAIRING_FAILED) { // Terminal pairing failure on a confirmed payment. Push so the customer // can come back to the app and see the failed-pairing screen / contact support. await sendPushNotification(UserType.CUSTOMER, customerId, { title: 'Sesi gagal', body: 'Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera.', data: { type: WsMessage.PAIRING_FAILED, payment_session_id: data.payment_session_id || '', cause_tag: data.cause_tag || '', }, }) } } } export const findAvailableMitras = async () => { const { max_customers_per_mitra } = await getMaxCustomersPerMitra() // Project active_session_count alongside the mitra row so the blast loop doesn't // need a per-mitra COUNT roundtrip later. const mitras = await sql` SELECT m.id, m.display_name, sub.active_session_count FROM mitras m INNER JOIN mitra_online_status s ON s.mitra_id = m.id INNER JOIN LATERAL ( SELECT COUNT(*)::int AS active_session_count FROM chat_sessions cs WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) ) sub ON true WHERE m.is_active = true AND s.is_online = true AND sub.active_session_count < ${max_customers_per_mitra} ` return mitras } /** * Validate that a payment session is owned by the customer, confirmed, and not yet consumed. * Throws on mismatch. Returns the loaded payment session row. */ const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { allowExtension = false } = {}) => { if (!paymentSessionId) { throw Object.assign(new Error('payment_session_id is required'), { code: 'VALIDATION_ERROR', statusCode: 422, }) } const paySession = await getPaymentSession(paymentSessionId) 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 && !allowExtension) { throw Object.assign(new Error('Extension payment session cannot be used to start a new chat'), { code: 'INVALID_STATE', statusCode: 409, }) } if (new Date(paySession.expires_at) <= new Date()) { // Check expiry inline at every state transition (defense in depth vs. the background sweeper). await failPaymentSession(paymentSessionId, PairingFailureCause.PAYMENT_SESSION_EXPIRED) throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 }) } return paySession } /** * General-blast pairing request. Requires a confirmed payment_session_id. * * The duration_minutes / price / is_first_session_discount values for the chat_session row are * sourced from the payment session — the client does not dictate pricing here. * * `allowTargetedPayment` is set true by the fallback-to-blast path: the original payment * was created with a `targeted_mitra_id` for "Curhat lagi" but the customer chose to * fall back to general blast on the same payment. The flag bypasses the * "use returning-chat endpoint" guard in that exact case. */ export const createPairingRequest = async (customerId, { paymentSessionId, topic_sensitivity, allowTargetedPayment = false } = {}) => { const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId) // Targeted payment session must use createTargetedPairingRequest unless we're // explicitly invoked by the fallback-to-blast path. if (paySession.targeted_mitra_id && !allowTargetedPayment) { throw Object.assign(new Error('Payment session is targeted to a specific mitra; use returning-chat endpoint'), { code: 'INVALID_STATE', statusCode: 409, }) } // Check for existing active session or request const [existing] = await sql` SELECT id, status FROM chat_sessions WHERE customer_id = ${customerId} AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.ACTIVE}) ` if (existing) { throw Object.assign(new Error('Customer already has an active session or request'), { code: 'ALREADY_ACTIVE', statusCode: 409, }) } const availableMitras = await findAvailableMitras() if (availableMitras.length === 0) { // No mitras to blast to — fail the payment immediately. await failPaymentSession(paymentSessionId, PairingFailureCause.NO_MITRA_AVAILABLE) throw Object.assign(new Error('No bestie available'), { code: 'NO_MITRA_AVAILABLE', statusCode: 404, }) } const resolvedTopic = topic_sensitivity === TopicSensitivity.SENSITIVE ? TopicSensitivity.SENSITIVE : TopicSensitivity.REGULAR // Create session sourced from the payment session. const [session] = await sql` INSERT INTO chat_sessions ( customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id ) VALUES ( ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount}, ${resolvedTopic}, ${paymentSessionId} ) RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at ` // Fan out to all available mitras in parallel — DB inserts and notifications are // independent per mitra. active_session_count was already projected by findAvailableMitras. await Promise.all(availableMitras.map(async (mitra) => { await sql` INSERT INTO chat_request_notifications (session_id, mitra_id, active_session_count) VALUES (${session.id}, ${mitra.id}, ${mitra.active_session_count}) ` await notifyMitra(mitra.id, { type: WsMessage.CHAT_REQUEST, session_id: session.id, request_type: PairingRequestType.GENERAL, created_at: session.created_at, duration_minutes: session.duration_minutes, is_first_session_discount: session.is_first_session_discount, topic_sensitivity: session.topic_sensitivity, }) })) // Start blast timeout (configurable via app_config) const { pairing_blast_timeout_seconds } = await getPairingBlastTimeoutSeconds() const timeoutId = setTimeout(async () => { try { await expirePairingRequest(session.id, PairingFailureCause.NO_MITRA_AVAILABLE) } catch (err) { console.error('expirePairingRequest failed', { sessionId: session.id, err }) } }, pairing_blast_timeout_seconds * 1000) pairingTimeouts.set(session.id, timeoutId) return session } /** * Targeted pairing request for "Curhat lagi" (returning chat). * * - Pre-check targeted mitra reachability + capacity. If unreachable or at-capacity-and-not-mid-session * with this customer → fail payment immediately and return 409 with `reason: 'targeted_mitra_offline'`. * - Fire ONE notification to the targeted mitra. * - Start a server-side timer of `returning_chat_confirmation_timeout_seconds`. On expiry, * mark request auto-rejected, fail payment with `targeted_mitra_timeout`, push WS event. * - On explicit decline by mitra: fail payment with `targeted_mitra_rejected`, push WS event. * - On accept: existing accept path runs (consumes payment session as for general blast). */ export const createTargetedPairingRequest = async (customerId, { paymentSessionId, targetedMitraId, topic_sensitivity } = {}) => { const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId) if (!targetedMitraId) { throw Object.assign(new Error('targetedMitraId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 }) } // Cross-check: payment_session.targeted_mitra_id should match (if set). if (paySession.targeted_mitra_id && paySession.targeted_mitra_id !== targetedMitraId) { throw Object.assign(new Error('targetedMitraId does not match payment session'), { code: 'INVALID_STATE', statusCode: 409, }) } // Check for existing active session or request const [existing] = await sql` SELECT id, status FROM chat_sessions WHERE customer_id = ${customerId} AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.ACTIVE}) ` if (existing) { throw Object.assign(new Error('Customer already has an active session or request'), { code: 'ALREADY_ACTIVE', statusCode: 409, }) } // Pre-check: mitra reachable? const reachable = await isMitraReachable(targetedMitraId) if (!reachable) { // Intermediate failure: audit row written, payment stays `confirmed` so the customer // can choose to fall back to general blast (or cancel, which terminates). await recordIntermediateFailure({ paymentSessionId, customerId, targetedMitraId, causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE, amount: paySession.amount, }) throw Object.assign(new Error('Targeted mitra is offline'), { code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline', }) } // Pre-check: mitra at capacity AND not mid-session with this customer? const { max_customers_per_mitra } = await getMaxCustomersPerMitra() const activeCount = await getMitraActiveSessionCount(targetedMitraId) if (activeCount >= max_customers_per_mitra) { const midSessionWithCustomer = await isMitraInActiveSessionWithCustomer(targetedMitraId, customerId) if (!midSessionWithCustomer) { await recordIntermediateFailure({ paymentSessionId, customerId, targetedMitraId, causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE, amount: paySession.amount, }) throw Object.assign(new Error('Targeted mitra is at capacity'), { code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline', }) } // Else: at-capacity but mid-session with the requesting customer — request allowed through. } const resolvedTopic = topic_sensitivity === TopicSensitivity.SENSITIVE ? TopicSensitivity.SENSITIVE : TopicSensitivity.REGULAR // Create session sourced from the payment session, status = pending_acceptance. const [session] = await sql` INSERT INTO chat_sessions ( customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id ) VALUES ( ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount}, ${resolvedTopic}, ${paymentSessionId} ) RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at ` // Single notification to the targeted mitra await sql` INSERT INTO chat_request_notifications (session_id, mitra_id, active_session_count) VALUES (${session.id}, ${targetedMitraId}, ${activeCount}) ` // Server-side timer (configurable, default 20s) — also surfaced in the WS payload so the mitra // app countdown UI matches what the server is enforcing. const { returning_chat_confirmation_timeout_seconds } = await getReturningChatConfirmationTimeoutSeconds() await notifyMitra(targetedMitraId, { type: WsMessage.CHAT_REQUEST, session_id: session.id, request_type: PairingRequestType.RETURNING, created_at: session.created_at, duration_minutes: session.duration_minutes, is_first_session_discount: session.is_first_session_discount, topic_sensitivity: session.topic_sensitivity, confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds, }) const timeoutId = setTimeout(async () => { try { await expireTargetedPairingRequest(session.id) } catch (err) { console.error('expireTargetedPairingRequest failed', { sessionId: session.id, err }) } }, returning_chat_confirmation_timeout_seconds * 1000) pairingTimeouts.set(session.id, timeoutId) // Surface the timeout to the customer so the targeted-waiting overlay countdown // matches the server-side timer exactly (CC-configurable; never stale). return { ...session, confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds } } export const acceptPairingRequest = async (sessionId, mitraId) => { // Use a transaction-like approach: update only if status is still pending_acceptance const [session] = await sql` UPDATE chat_sessions SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW() WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL RETURNING id, customer_id, mitra_id, status, paired_at, payment_session_id ` if (!session) { throw Object.assign(new Error('Request already accepted or expired'), { code: 'REQUEST_UNAVAILABLE', statusCode: 409, }) } // Mark this mitra's notification as accepted await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.ACCEPTED}, responded_at = NOW() WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} ` // Mark other mitras' notifications as missed (another mitra accepted) await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.MISSED}, responded_at = NOW() WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL ` // Clear timeout const timeoutId = pairingTimeouts.get(sessionId) if (timeoutId) { clearTimeout(timeoutId) pairingTimeouts.delete(sessionId) } // Consume the payment session at the moment of acceptance. if (session.payment_session_id) { await consumePaymentSession(session.payment_session_id) } // Activate the session and set expires_at. const [activeSession] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.ACTIVE}, expires_at = CASE WHEN duration_minutes IS NOT NULL THEN NOW() + (duration_minutes || ' minutes')::interval ELSE NULL END WHERE id = ${sessionId} RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_session_id ` // Record transaction if (activeSession.duration_minutes) { const txType = activeSession.is_first_session_discount ? TransactionType.FIRST_SESSION_DISCOUNT : TransactionType.PAID await sql` INSERT INTO customer_transactions (customer_id, session_id, type, amount) VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0}) ` } // Start session timer if duration is set if (activeSession.expires_at) { startSessionTimer(sessionId, activeSession.expires_at) } // Start chat message listener for this session startSessionListener(sessionId) // Get mitra display name for customer notification const [mitra] = await sql` SELECT display_name FROM mitras WHERE id = ${mitraId} ` // Notify customer via WebSocket (FCM fallback) await notifyCustomer(activeSession.customer_id, { type: WsMessage.PAIRED, session_id: sessionId, mitra_display_name: mitra.display_name, status: SessionStatus.ACTIVE, }) // Notify other mitras to dismiss the request — independent fan-out, run in parallel. const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} ` await Promise.all(notifications.map((n) => notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, reason: 'accepted_by_other', }))) return activeSession } export const declinePairingRequest = async (sessionId, mitraId) => { await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.DECLINED}, responded_at = NOW() WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL ` // Targeted-vs-general is determined by the payment_session.targeted_mitra_id, not by // notification count — a general blast with only one online mitra also has length=1. const [targetCheck] = await sql` SELECT ps.targeted_mitra_id FROM chat_sessions cs LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id WHERE cs.id = ${sessionId} ` const isTargeted = !!targetCheck?.targeted_mitra_id if (isTargeted) { // Mark the chat_session as expired (the targeted attempt is over) — but keep the // payment_session in `confirmed` so the customer can fall back to general blast on // the same payment, or cancel (which then terminates). const [session] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} RETURNING id, customer_id, payment_session_id ` if (session) { // Clear the 20s timer if still pending. const timeoutId = pairingTimeouts.get(sessionId) if (timeoutId) { clearTimeout(timeoutId) pairingTimeouts.delete(sessionId) } // Audit row only; payment session stays `confirmed`. if (session.payment_session_id) { const paySession = await getPaymentSession(session.payment_session_id) if (paySession) { await recordIntermediateFailure({ paymentSessionId: session.payment_session_id, customerId: session.customer_id, targetedMitraId: mitraId, causeTag: PairingFailureCause.TARGETED_MITRA_REJECTED, amount: paySession.amount, }) } } // Push a returning-chat-rejected WS event to the customer (fall-through to fallback flow). await notifyCustomer(session.customer_id, { type: WsMessage.RETURNING_CHAT_REJECTED, session_id: sessionId, payment_session_id: session.payment_session_id, }) } return } // General-blast: if all notifications now have a non-null DECLINED response → treat as // every-mitra-rejected (terminal, distinct from blast-window-timeout). Empty-array guard // prevents a misfire when the SELECT happens to return zero rows. const notifications = await sql` SELECT response FROM chat_request_notifications WHERE session_id = ${sessionId} ` const allDeclined = notifications.length > 0 && notifications.every((n) => n.response === NotificationResponse.DECLINED) if (allDeclined) { const [session] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} RETURNING id, customer_id, payment_session_id ` if (session) { const timeoutId = pairingTimeouts.get(sessionId) if (timeoutId) { clearTimeout(timeoutId) pairingTimeouts.delete(sessionId) } if (session.payment_session_id) { await failPaymentSession(session.payment_session_id, PairingFailureCause.ALL_MITRAS_REJECTED) } // Terminal: customer is in a searching state and the search just ended with no chat. await notifyCustomer(session.customer_id, { type: WsMessage.PAIRING_FAILED, session_id: sessionId, payment_session_id: session.payment_session_id, cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED, }) } } } export const cancelPairingRequest = async (sessionId, customerId) => { const [session] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.CANCELLED} WHERE id = ${sessionId} AND customer_id = ${customerId} AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) RETURNING id, customer_id, status, payment_session_id ` if (!session) { throw Object.assign(new Error('Cannot cancel this request'), { code: 'CANNOT_CANCEL', statusCode: 409, }) } // Clear timeout const timeoutId = pairingTimeouts.get(sessionId) if (timeoutId) { clearTimeout(timeoutId) pairingTimeouts.delete(sessionId) } // Mark all notifications as ignored await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.IGNORED}, responded_at = NOW() WHERE session_id = ${sessionId} AND response IS NULL ` // Notify mitras to dismiss (customer cancelled) — independent fan-out, run in parallel. const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` await Promise.all(notifications.map((n) => notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, reason: 'cancelled_by_customer', }))) // Customer initiated this cancel; the calling client already navigates home. Do not // push PAIRING_FAILED for customer-initiated cancels — surfacing it as a "failure" // event (especially via FCM if backgrounded) misframes the user's own action. if (session.payment_session_id) { await failPaymentSession(session.payment_session_id, PairingFailureCause.CUSTOMER_CANCELLED) } return session } /** * Customer-initiated cancel during a payment-search. * * Use this when the customer is sitting on the searching/waiting screen with a confirmed * payment but no chat-session row yet exists, OR when they're in a returning-chat 20s wait. * If a chat_session was already created (general blast in flight, or targeted request out), * we cancel that too. */ export const cancelPaymentSearch = async (paymentSessionId, customerId) => { const paySession = await getPaymentSession(paymentSessionId) 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 a chat_session exists for this payment in pending_acceptance/searching, cancel it. const [linkedSession] = await sql` SELECT id FROM chat_sessions WHERE payment_session_id = ${paymentSessionId} AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) ` if (linkedSession) { // cancelPairingRequest also fails the payment session — short-circuit to avoid double work. return cancelPairingRequest(linkedSession.id, customerId) } // Otherwise fail the payment directly. Covers the case where the customer cancels after // the targeted attempt already expired/rejected (chat_session no longer pending_acceptance) // but the payment is still `confirmed`. No customer-side WS push — see cancelPairingRequest. if (paySession.status === PaymentSessionStatus.CONFIRMED) { await failPaymentSession(paymentSessionId, PairingFailureCause.CUSTOMER_CANCELLED) } return { id: paymentSessionId, payment_session_id: paymentSessionId } } /** * After a returning-chat fail, customer taps "Chat dengan bestie lain". * * The original payment_session stays in `confirmed` for the entire returning-chat flow — * targeted reject/timeout writes an audit-only `pairing_failures` row but does NOT terminate. * So when the customer falls back to general blast, we reuse the same `payment_session_id` * directly. Multiple `pairing_failures` rows may FK from one payment_session — that's the * desired CC UX (one row per failed attempt). Termination happens only at the actual end * of the flow (chat starts → consumed; cancel/blast-exhaust → failed_pairing). * * The targeted_mitra_id flag on the original row is left as-is (it records the customer's * original intent); the general blast happens regardless. */ export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { topic_sensitivity } = {}) => { const paySession = await getPaymentSession(paymentSessionId) 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(`Cannot fallback from payment in status ${paySession.status}`), { code: 'INVALID_STATE', statusCode: 409, }) } // Run the general blast against the SAME payment session. Pass `allowTargetedPayment` // so the targeted_mitra_id on the payment session doesn't trip the general-blast guard. return createPairingRequest(customerId, { paymentSessionId, topic_sensitivity, allowTargetedPayment: true, }) } export const expirePairingRequest = async (sessionId, causeTag = PairingFailureCause.NO_MITRA_AVAILABLE) => { const [session] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} RETURNING id, customer_id, status, payment_session_id ` if (!session) return null pairingTimeouts.delete(sessionId) // Mark all pending notifications as ignored await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.IGNORED}, responded_at = NOW() WHERE session_id = ${sessionId} AND response IS NULL ` // Fail the payment session (if any) — terminal. if (session.payment_session_id) { await failPaymentSession(session.payment_session_id, causeTag) } // Notify customer via WebSocket (FCM fallback). Terminal pairing failure → PAIRING_FAILED // so the client can route to the failed-pairing screen consistently with the other // terminal paths (cancel / all-rejected / payment-expired-mid-search). await notifyCustomer(session.customer_id, { type: WsMessage.PAIRING_FAILED, session_id: sessionId, payment_session_id: session.payment_session_id, cause_tag: causeTag, }) // Notify mitras to dismiss (request expired) — independent fan-out, run in parallel. const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` await Promise.all(notifications.map((n) => notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, reason: 'expired', }))) return session } /** * Targeted-request timer fired with no mitra response. * * INTERMEDIATE failure: the chat_session is marked expired (the targeted attempt is over) * but the payment_session stays `confirmed` so the customer can fall back to general blast * on the same payment, or cancel (which then terminates). * * - cause_tag is targeted_mitra_timeout (audit row only) * - WS event sent to customer is RETURNING_CHAT_TIMEOUT (not PAIRING_FAILED) */ const expireTargetedPairingRequest = async (sessionId) => { const [session] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} RETURNING id, customer_id, status, payment_session_id ` if (!session) return null pairingTimeouts.delete(sessionId) // Capture which mitra was targeted (for the audit row). const [notif] = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} LIMIT 1 ` await sql` UPDATE chat_request_notifications SET response = ${NotificationResponse.IGNORED}, responded_at = NOW() WHERE session_id = ${sessionId} AND response IS NULL ` if (session.payment_session_id) { const paySession = await getPaymentSession(session.payment_session_id) if (paySession) { await recordIntermediateFailure({ paymentSessionId: session.payment_session_id, customerId: session.customer_id, targetedMitraId: notif?.mitra_id ?? null, causeTag: PairingFailureCause.TARGETED_MITRA_TIMEOUT, amount: paySession.amount, }) } } await notifyCustomer(session.customer_id, { type: WsMessage.RETURNING_CHAT_TIMEOUT, session_id: sessionId, payment_session_id: session.payment_session_id, }) // Notify the targeted mitra that the card is no longer actionable — fan-out in parallel // (single recipient today, but cheap to future-proof). const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` await Promise.all(notifications.map((n) => notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, reason: 'timeout', }))) return session } export const getPendingRequestsForMitra = async (mitraId) => { // Distinguish general blast from "Curhat lagi" returning requests via payment_session.targeted_mitra_id. // For returning requests, surface the configured timeout so the cold-start (FCM-tap) path can render // the countdown overlay — same field the WS payload provides for the live path. const rows = await sql` SELECT cs.id AS session_id, cs.duration_minutes, cs.is_first_session_discount, cs.topic_sensitivity, cs.created_at, CASE WHEN ps.targeted_mitra_id IS NOT NULL THEN ${PairingRequestType.RETURNING} ELSE ${PairingRequestType.GENERAL} END AS request_type FROM chat_request_notifications crn JOIN chat_sessions cs ON cs.id = crn.session_id LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id WHERE crn.mitra_id = ${mitraId} AND crn.response IS NULL AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE} ORDER BY cs.created_at ASC ` if (!rows.some((r) => r.request_type === PairingRequestType.RETURNING)) { return rows } // At least one returning row — fetch the timeout config once and attach. const { returning_chat_confirmation_timeout_seconds } = await getReturningChatConfirmationTimeoutSeconds() return rows.map((r) => r.request_type === PairingRequestType.RETURNING ? { ...r, confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds } : r ) } export const getSessionStatus = async (sessionId) => { const [session] = await sql` SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by, m.display_name AS mitra_display_name FROM chat_sessions cs LEFT JOIN mitras m ON m.id = cs.mitra_id WHERE cs.id = ${sessionId} ` return session }