Backend half of Stage 10 — the new Chat tab in the customer app that replaces /chat/history with a 3-sub-tab list (Aktif / Pembayaran / Selesai). - New GET /api/client/payment-sessions/pending — returns the customer's pending initial + extension payment sessions. Filter is status='pending' AND expires_at > NOW(). Mitra info comes from session_extensions → chat_sessions for extension rows, payment_sessions.targeted_mitra_id for targeted-curhat-lagi initial rows. TTL reuses the existing payment_session_timeout_minutes app_config row (default 20m) — no new config row needed since payment is still mocked. - getCustomerHistory migrated from offset (page/limit) to cursor pagination. Cursor is base64url(`<endedAtIso>|<id>`) with id-tiebreak in ORDER BY so rows with identical timestamps don't duplicate or skip across pages. SELECT now JOINs payment_sessions to surface `mode` (chat/call) for the Selesai-row voice-call pill. - requirement/flow_customer.mermaid.md: new §7 Chat Tab subgraph + Figma cross-ref entry for SChatList. - requirement/phase4-customer-flow-plan.md: Stage 10 plan section. Also carries forward earlier uncommitted "Post-Stage-8 corrections" notes from the Stage 9 sweep (boot path / SHome1st / onboarding fixes). Tests: +7 for getCustomerPendingPayments (initial null mitra, targeted-mitra fill, extension-via-session JOIN, mixed-newest-first, expired excluded, non-pending excluded, customer scoping). +10 for cursor history (empty, exact-fit, multi-page walk, same-timestamp tiebreak, limit clamp, customer scoping, CLOSING+COMPLETED only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
215 lines
8.4 KiB
JavaScript
215 lines
8.4 KiB
JavaScript
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 })
|
|
})
|
|
|
|
// Phase 4 Stage 10 — Chat Tab Selesai feed. Cursor-paginated; old `page`
|
|
// param removed. Response shape: { items, next_cursor, has_more }.
|
|
app.get('/history', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
|
const { cursor, limit } = request.query
|
|
const history = await getCustomerHistory(request.customer.id, {
|
|
cursor: cursor ?? null,
|
|
limit: limit ? parseInt(limit, 10) : 20,
|
|
})
|
|
return reply.send({ success: true, data: history })
|
|
})
|
|
}
|