import { authenticate } from '../../plugins/auth.js' import { getCustomerById } from '../../services/customer.service.js' import { createPairingRequest, createTargetedPairingRequest, cancelPairingRequest, cancelPaymentSearch, fallbackToGeneralBlast, } from '../../services/pairing.service.js' import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory, } from '../../services/session.service.js' import { getPricingForCustomer } from '../../services/pricing.service.js' import { requestExtension } from '../../services/extension.service.js' import { EndedBy, TopicSensitivity, UserType } from '../../constants.js' const resolveCustomer = async (request, reply) => { if (request.auth?.userType !== UserType.CUSTOMER) { return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Customer account required' }, }) } const customer = await getCustomerById(request.auth.userId) if (!customer) { return reply.code(404).send({ success: false, error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' }, }) } request.customer = customer } export const clientChatRoutes = async (app) => { // Get chat + call pricing tiers + first-session-discount eligibility (per-customer). // Phase 4 reshape — tiers come from `app_config.pricing_{chat,call}_tiers_json` and // discount eligibility is the AND of: phone-verified + no completed sessions + // first_session_discount_enabled. app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const pricing = await getPricingForCustomer(request.customer.id) return reply.send({ success: true, data: pricing }) }) /** * Start a general-blast pairing search. * * Body MUST include `payment_session_id` (a confirmed payment_session owned by the caller). * Pricing/duration/free-trial values are sourced from the payment session, NOT from the client. */ app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const { payment_session_id, topic_sensitivity } = request.body ?? {} if (!payment_session_id) { return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' }, }) } if (topic_sensitivity !== TopicSensitivity.REGULAR && topic_sensitivity !== TopicSensitivity.SENSITIVE) { return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'topic_sensitivity must be regular or sensitive' }, }) } const session = await createPairingRequest(request.customer.id, { paymentSessionId: payment_session_id, topic_sensitivity, }) return reply.code(201).send({ success: true, data: session }) }) /** * Start a targeted "Curhat lagi" pairing request. * * Body: { payment_session_id, mitra_id, topic_sensitivity? } * Returns 409 with reason: 'targeted_mitra_offline' if the targeted mitra is unreachable * or at capacity. The payment session stays `confirmed` in that case so the customer * can fall back to general blast on the same payment. */ app.post('/chat-requests/returning', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const { payment_session_id, mitra_id, topic_sensitivity } = request.body ?? {} if (!payment_session_id) { return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' }, }) } if (!mitra_id) { return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'mitra_id is required' }, }) } const resolvedTopic = (topic_sensitivity === TopicSensitivity.SENSITIVE) ? TopicSensitivity.SENSITIVE : TopicSensitivity.REGULAR const session = await createTargetedPairingRequest(request.customer.id, { paymentSessionId: payment_session_id, targetedMitraId: mitra_id, topic_sensitivity: resolvedTopic, }) return reply.code(201).send({ success: true, data: session }) }) /** * Customer-initiated cancel during searching/waiting. * * Body: { payment_session_id } * Terminal — payment session moves to failed_pairing with cause = customer_cancelled. */ app.post('/chat-requests/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const { payment_session_id } = request.body ?? {} if (!payment_session_id) { return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' }, }) } const result = await cancelPaymentSearch(payment_session_id, request.customer.id) return reply.send({ success: true, data: result }) }) /** * After a returning-chat fail, customer taps "Chat dengan bestie lain". * Reuses the same payment_session_id (no double-charge), runs general blast. */ app.post('/chat-requests/:paymentSessionId/fallback-to-blast', { preHandler: [authenticate, resolveCustomer], }, async (request, reply) => { const { topic_sensitivity } = request.body ?? {} const resolvedTopic = (topic_sensitivity === TopicSensitivity.SENSITIVE) ? TopicSensitivity.SENSITIVE : TopicSensitivity.REGULAR const session = await fallbackToGeneralBlast( request.params.paymentSessionId, request.customer.id, { topic_sensitivity: resolvedTopic }, ) return reply.code(201).send({ success: true, data: session }) }) /** * Cancel-by-session-id retained for in-flight chat_session cancels (e.g. cancel * during the 20s targeted wait after a chat_session has been created). Customer cancel * via payment_session_id should prefer POST /chat-requests/cancel above. */ app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const session = await cancelPairingRequest(request.params.sessionId, request.customer.id) return reply.send({ success: true, data: session }) }) app.get('/session/active', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const session = await getActiveSessionByCustomer(request.customer.id) return reply.send({ success: true, data: session ?? null }) }) app.get('/session/active-with-unread', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const session = await getActiveSessionByCustomerWithUnread(request.customer.id) return reply.send({ success: true, data: session ?? null }) }) app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const session = await endSession(request.params.sessionId, EndedBy.CUSTOMER, request.customer.id) return reply.send({ success: true, data: session }) }) /** * Extension request REQUIRES `extension_payment_session_id`. * The payment session must be is_extension=true and is_first_session_discount=false. * Pricing/duration come from the payment session via the extension service. */ app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const { duration_minutes, price, extension_payment_session_id } = request.body ?? {} if (!extension_payment_session_id) { return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'extension_payment_session_id is required' }, }) } if (!duration_minutes || price === undefined) { return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' }, }) } const extension = await requestExtension(request.params.sessionId, request.customer.id, { duration_minutes, price, extension_payment_session_id, }) return reply.send({ success: true, data: extension }) }) // Chat history app.get('/history', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const { page, limit } = request.query const history = await getCustomerHistory(request.customer.id, { page: page ? parseInt(page) : 1, limit: limit ? parseInt(limit) : 20, }) return reply.send({ success: true, data: history }) }) }