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:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

View File

@@ -1,6 +1,7 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserById } from '../../services/cc-user.service.js'
import { UserType } from '../../constants.js'
import { UserType, ExtensionTimeoutAction } from '../../constants.js'
import { publish } from '../../plugins/valkey.js'
import {
getAnonymityConfig, setAnonymityConfig,
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
@@ -9,6 +10,10 @@ import {
getEarlyEndConfig, setEarlyEndConfig,
getMitraPingConfig, setMitraPingConfig,
getSensitivityConfig, setSensitivityConfig,
getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes,
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
} from '../../services/config.service.js'
const attachCcUser = async (request, reply) => {
@@ -21,6 +26,17 @@ const attachCcUser = async (request, reply) => {
}
export const internalConfigRoutes = async (app) => {
// Cross-instance config invalidate. Local mutators (e.g. setMaxCustomersPerMitra) bust
// their own in-process caches directly; this publish fans out to other instances.
const publishConfigInvalidate = async (key) => {
try {
await publish('config:invalidate', { key, ts: Date.now() })
} catch (err) {
// Valkey may be down in dev. Local invalidate already happened.
app.log.warn({ err, key }, 'config invalidate publish failed')
}
}
app.get('/anonymity', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
@@ -54,6 +70,7 @@ export const internalConfigRoutes = async (app) => {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'max_customers_per_mitra must be a positive number' } })
}
const config = await setMaxCustomersPerMitra(max_customers_per_mitra)
await publishConfigInvalidate('max_customers_per_mitra')
return reply.send({ success: true, data: config })
})
@@ -178,4 +195,93 @@ export const internalConfigRoutes = async (app) => {
await sql`UPDATE app_config SET value = ${sql.json({ tiers })}, updated_at = NOW() WHERE key = 'price_tiers'`
return reply.send({ success: true, data: tiers })
})
// --- Paid pairing flow + extension flip ---
app.get('/payment-session-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getPaymentSessionTimeoutMinutes() })
})
app.patch('/payment-session-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { payment_session_timeout_minutes } = request.body ?? {}
if (typeof payment_session_timeout_minutes !== 'number' || payment_session_timeout_minutes < 1) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'payment_session_timeout_minutes must be a number >= 1' },
})
}
const config = await setPaymentSessionTimeoutMinutes(payment_session_timeout_minutes)
await publishConfigInvalidate('payment_session_timeout_minutes')
return reply.send({ success: true, data: config })
})
app.get('/returning-chat-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getReturningChatConfirmationTimeoutSeconds() })
})
app.patch('/returning-chat-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { returning_chat_confirmation_timeout_seconds } = request.body ?? {}
if (typeof returning_chat_confirmation_timeout_seconds !== 'number' || returning_chat_confirmation_timeout_seconds < 5) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'returning_chat_confirmation_timeout_seconds must be a number >= 5' },
})
}
const config = await setReturningChatConfirmationTimeoutSeconds(returning_chat_confirmation_timeout_seconds)
await publishConfigInvalidate('returning_chat_confirmation_timeout_seconds')
return reply.send({ success: true, data: config })
})
app.get('/extension-default-action', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getExtensionDefaultActionOnTimeout() })
})
app.patch('/extension-default-action', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { extension_default_action_on_timeout } = request.body ?? {}
if (!Object.values(ExtensionTimeoutAction).includes(extension_default_action_on_timeout)) {
return reply.code(422).send({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: `extension_default_action_on_timeout must be one of: ${Object.values(ExtensionTimeoutAction).join(', ')}`,
},
})
}
const config = await setExtensionDefaultActionOnTimeout(extension_default_action_on_timeout)
await publishConfigInvalidate('extension_default_action_on_timeout')
return reply.send({ success: true, data: config })
})
app.get('/pairing-blast-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getPairingBlastTimeoutSeconds() })
})
app.patch('/pairing-blast-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { pairing_blast_timeout_seconds } = request.body ?? {}
if (typeof pairing_blast_timeout_seconds !== 'number' || pairing_blast_timeout_seconds < 5) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'pairing_blast_timeout_seconds must be a number >= 5' },
})
}
const config = await setPairingBlastTimeoutSeconds(pairing_blast_timeout_seconds)
await publishConfigInvalidate('pairing_blast_timeout_seconds')
return reply.send({ success: true, data: config })
})
}

View File

@@ -0,0 +1,69 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserById } from '../../services/cc-user.service.js'
import { listFailures, setOperatorAction } from '../../services/pairing-failure.service.js'
import { UserType, PairingFailureCause, PairingFailureOperatorAction } from '../../constants.js'
const attachCcUser = async (request, reply) => {
if (request.auth?.userType !== UserType.CC_USER) {
return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
}
const user = await getCcUserById(request.auth.userId)
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user
}
const VALID_CAUSE_TAGS = new Set(Object.values(PairingFailureCause))
const VALID_ACTIONS = new Set(Object.values(PairingFailureOperatorAction))
/**
* Control-center "Failed Pairings" review screen backend.
*
* GET /internal/failed-pairings
* POST /internal/failed-pairings/:id/action
*/
export const failedPairingsRoutes = async (app) => {
app.get('/', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const { cause_tags, date_from, date_to, limit = 50, offset = 0 } = request.query ?? {}
// cause_tags can arrive as a single string (?cause_tags=foo) or an array
// (?cause_tags=foo&cause_tags=bar). Normalize and validate.
let causeTagsArr = null
if (cause_tags !== undefined) {
causeTagsArr = Array.isArray(cause_tags) ? cause_tags : [cause_tags]
for (const tag of causeTagsArr) {
if (!VALID_CAUSE_TAGS.has(tag)) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: `Unknown cause_tag: ${tag}` },
})
}
}
}
const result = await listFailures({
causeTags: causeTagsArr,
dateFrom: date_from || null,
dateTo: date_to || null,
limit: Number(limit) || 50,
offset: Number(offset) || 0,
})
return reply.send({ success: true, data: result })
})
app.post('/:id/action', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { action } = request.body ?? {}
if (!VALID_ACTIONS.has(action)) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: `action must be one of: ${[...VALID_ACTIONS].join(', ')}` },
})
}
const updated = await setOperatorAction(request.params.id, request.ccUser.id, action)
return reply.send({ success: true, data: updated })
})
}

View File

@@ -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 })
})

View File

@@ -0,0 +1,27 @@
import { authenticate } from '../../plugins/auth.js'
import { countAvailableMitrasFromCache } from '../../services/mitra-status.service.js'
import { UserType } from '../../constants.js'
/**
* Customer-home availability poll.
*
* GET /api/client/mitra-availability → 200 { available: bool, count?: number }
*
* Hot endpoint by design — polled every 5s per active customer while their home is
* foregrounded. Backed by a 10s in-memory cache (see mitra-status.service.js) so DB load
* stays bounded regardless of poller count. No rate limit by intent.
*
* `count` is included for CC/debug; the customer UI must read only `available`.
*/
export const clientMitraAvailabilityRoutes = async (app) => {
app.get('/', { preHandler: [authenticate] }, async (request, reply) => {
if (request.auth?.userType !== UserType.CUSTOMER) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Customer account required' },
})
}
const result = await countAvailableMitrasFromCache()
return reply.send({ success: true, data: result })
})
}

View File

@@ -0,0 +1,155 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerById } from '../../services/customer.service.js'
import {
createPaymentSession,
confirmPaymentSession,
abandonPaymentSession,
getPaymentSession,
} from '../../services/payment.service.js'
import {
isCustomerEligibleForFreeTrial,
isValidTier,
getPriceTiers,
} from '../../services/pricing.service.js'
import { 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
}
/**
* Payment session lifecycle (mocked — no Xendit yet).
*
* POST /api/client/payment-sessions
* POST /api/client/payment-sessions/:id/confirm
* POST /api/client/payment-sessions/:id/cancel
* GET /api/client/payment-sessions/:id
*/
export const clientPaymentRoutes = async (app) => {
// Create a payment session (status = pending). Free-trial logic is server-side: if the
// customer is eligible AND this is NOT an extension, amount is forced to 0 and
// is_free_trial = true regardless of what the client passes.
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const {
duration_minutes,
targeted_mitra_id = null,
is_extension = false,
} = request.body ?? {}
if (typeof duration_minutes !== 'number' || duration_minutes <= 0) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' },
})
}
// Free trial: never for extensions.
let isFreeTrial = false
let amount
if (!is_extension) {
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
if (eligible) {
isFreeTrial = true
amount = 0
}
}
if (!isFreeTrial) {
// Resolve amount from the price tiers (duration-keyed). The client passes
// duration_minutes; we look up the matching tier to get the canonical price.
const tiers = await getPriceTiers()
const tier = tiers.find((t) => t.duration_minutes === duration_minutes)
if (!tier) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested duration' },
})
}
amount = tier.price
// Sanity check (defense-in-depth) — duration+price should match a known tier.
if (!(await isValidTier(duration_minutes, amount))) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
})
}
}
const session = await createPaymentSession({
customerId: request.customer.id,
durationMinutes: duration_minutes,
amount,
isFreeTrial,
isExtension: Boolean(is_extension),
targetedMitraId: targeted_mitra_id || null,
})
return reply.code(201).send({
success: true,
data: {
id: session.id,
amount: session.amount,
duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial,
is_extension: session.is_extension,
targeted_mitra_id: session.targeted_mitra_id,
expires_at: session.expires_at,
status: session.status,
},
})
})
app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await confirmPaymentSession(request.params.id, request.customer.id)
return reply.send({
success: true,
data: {
id: session.id,
status: session.status,
confirmed_at: session.confirmed_at,
},
})
})
app.post('/:id/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await abandonPaymentSession(request.params.id, request.customer.id)
return reply.send({
success: true,
data: {
id: session.id,
status: session.status,
},
})
})
app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await getPaymentSession(request.params.id)
if (!session) {
return reply.code(404).send({
success: false,
error: { code: 'NOT_FOUND', message: 'Payment session not found' },
})
}
if (session.customer_id !== request.customer.id) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Payment session does not belong to this customer' },
})
}
return reply.send({ success: true, data: session })
})
}