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,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 })
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user