Phase 3.7: paid pairing flow + returning chat + extension flip
- Backend: payment_sessions + pairing_failures tables; payment.service.js and pairing-failure.service.js (new); rewritten pairing.service.js (payment-gated blast + targeted "Curhat lagi" + cancel + fallback); rewritten extension.service.js (data-driven auto-approve with offline safeguard, charge-at-approval); pricing.service.js (extension tiers without free trial); mitra-status.service.js (countAvailableMitras cached path); 60s sweeper for stale payment sessions - Backend routes: client.payment.routes, client.mitra-availability.routes, internal/failed-pairings.routes; client.chat.routes rewritten for payment-gated start + /returning + /cancel + /fallback-to-blast; internal/config.routes adds 4 new keys with Valkey invalidate publish - client_app: mitra-availability poll, payment screen + notifier, pairing notifier rewrite (PairingTargetedWaiting + PairingFailed states), targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi" CTA, failed-pairing terminal, extension via payment-session - mitra_app: PairingRequestType enum, returning-chat 20s countdown auto-dismiss, extension card "otomatis disetujui" copy - control_center: 4 new config rows in Settings, Failed Pairings page (filter + paginate + action menu), sidebar + route registered - Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4 pass), Maestro mobile scaffold (CLI install pending) - Bugs found via Playwright + fixed: LoginPage labels not associated with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE in allow-methods (silent settings breakage in browsers since Stage 4) - Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A), phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md (today's run results) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,19 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getCustomerById } from '../../services/customer.service.js'
|
||||
import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js'
|
||||
import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory } from '../../services/session.service.js'
|
||||
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.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'
|
||||
|
||||
@@ -30,8 +41,21 @@ export const clientChatRoutes = async (app) => {
|
||||
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 { duration_minutes, price, is_free_trial, topic_sensitivity } = request.body || {}
|
||||
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({
|
||||
@@ -40,43 +64,91 @@ export const clientChatRoutes = async (app) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Validate selection
|
||||
if (is_free_trial) {
|
||||
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
|
||||
if (!eligible) {
|
||||
return reply.code(403).send({
|
||||
success: false,
|
||||
error: { code: 'FREE_TRIAL_INELIGIBLE', message: 'Not eligible for free trial' },
|
||||
})
|
||||
}
|
||||
const freeTrial = await getFreeTrial()
|
||||
const session = await createPairingRequest(request.customer.id, {
|
||||
duration_minutes: freeTrial.duration_minutes,
|
||||
price: 0,
|
||||
is_free_trial: true,
|
||||
topic_sensitivity,
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: session })
|
||||
}
|
||||
|
||||
if (!duration_minutes || price === undefined) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' },
|
||||
})
|
||||
}
|
||||
|
||||
if (!(await isValidTier(duration_minutes, price))) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
||||
})
|
||||
}
|
||||
|
||||
const session = await createPairingRequest(request.customer.id, { duration_minutes, price, is_free_trial: false, topic_sensitivity })
|
||||
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 })
|
||||
@@ -97,16 +169,32 @@ export const clientChatRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: session })
|
||||
})
|
||||
|
||||
// Request session extension
|
||||
/**
|
||||
* Extension request REQUIRES `extension_payment_session_id`.
|
||||
* The payment session must be is_extension=true and is_free_trial=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 } = request.body || {}
|
||||
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 })
|
||||
|
||||
const extension = await requestExtension(request.params.sessionId, request.customer.id, {
|
||||
duration_minutes,
|
||||
price,
|
||||
extension_payment_session_id,
|
||||
})
|
||||
return reply.send({ success: true, data: extension })
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user