Phase 4 Stage 1: backend foundation (additive endpoints + schema)

Schema (idempotent migration):
- payment_sessions.is_free_trial -> is_first_session_discount (data copied)
- payment_sessions.mode TEXT NOT NULL DEFAULT 'chat' CHECK (chat|call)
- chat_sessions.topics TEXT[] for ESP picks (info-only)

New endpoints:
- GET /api/client/onboarding-state (drives verif sheet + S6 paywall gate)
- GET /api/client/chat-pricing (rewrite: chat+call groups + first-session
  discount block, per-customer eligibility)
- GET /api/shared/auth-providers (env-probed; replaces ENABLE_SOCIAL_AUTH
  build flag — frontend cutover lands in stage 2)
- GET /api/client/support-handles (Tanya Admin handles, CC-config-driven)

session_warning WS event fires once at 180s remaining.

app_config seeds (mock pricing tiers, first-session discount, support
handles, payment method order, end-session 2-step toggle).

CC SettingsPage: 3 new sections (first-session discount, pricing tiers
JSON editors, support handles).

15/15 Vitest passing. chat_sessions.is_free_trial also renamed for
consistency (plan only specified payment_sessions; pairing.service.js
read both).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 15:56:28 +08:00
parent 4ada7c991a
commit d33d4419ea
24 changed files with 1347 additions and 162 deletions

View File

@@ -14,6 +14,9 @@ import {
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
getFirstSessionDiscountConfig, setFirstSessionDiscountConfig,
getSupportHandles, setSupportHandles,
getPricingTierGroups, setPricingTierGroup,
} from '../../services/config.service.js'
const attachCcUser = async (request, reply) => {
@@ -284,4 +287,104 @@ export const internalConfigRoutes = async (app) => {
await publishConfigInvalidate('pairing_blast_timeout_seconds')
return reply.send({ success: true, data: config })
})
// --- Phase 4: First-session discount ---
app.get('/first-session-discount', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getFirstSessionDiscountConfig() })
})
app.patch('/first-session-discount', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes } = request.body ?? {}
const patch = {}
if (enabled !== undefined) {
if (typeof enabled !== 'boolean') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'enabled must be a boolean' } })
}
patch.enabled = enabled
}
for (const [field, value] of [
['actual_price_idr', actual_price_idr],
['gimmick_price_idr', gimmick_price_idr],
['duration_minutes', duration_minutes],
]) {
if (value !== undefined) {
if (typeof value !== 'number' || value < 0 || !Number.isFinite(value)) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: `${field} must be a non-negative number` } })
}
patch[field] = Math.round(value)
}
}
if (modes !== undefined) {
if (!Array.isArray(modes) || modes.some((m) => m !== 'chat' && m !== 'call')) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'modes must be an array of "chat" | "call"' } })
}
patch.modes = modes
}
const config = await setFirstSessionDiscountConfig(patch)
await publishConfigInvalidate('first_session_discount')
return reply.send({ success: true, data: config })
})
// --- Phase 4: Pricing tier groups (chat / call) ---
app.get('/pricing-tiers', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getPricingTierGroups() })
})
app.patch('/pricing-tiers/:mode', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const mode = request.params.mode
if (mode !== 'chat' && mode !== 'call') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' } })
}
const { tiers } = request.body ?? {}
if (!Array.isArray(tiers) || tiers.length === 0) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'tiers must be a non-empty array' } })
}
for (const t of tiers) {
if (
typeof t.id !== 'string'
|| typeof t.minutes !== 'number' || t.minutes <= 0
|| typeof t.price_idr !== 'number' || t.price_idr < 0
) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'each tier needs id (string), minutes (number > 0), price_idr (number >= 0)' } })
}
}
const config = await setPricingTierGroup(mode, tiers)
await publishConfigInvalidate(`pricing_${mode}_tiers_json`)
return reply.send({ success: true, data: config })
})
// --- Phase 4: Support handles ---
app.get('/support-handles', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getSupportHandles() })
})
app.patch('/support-handles', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { wa, telegram } = request.body ?? {}
const validateHandle = (h, name) => {
if (h === undefined) return null
if (typeof h !== 'object' || h === null) return `${name} must be an object`
if (h.label !== undefined && typeof h.label !== 'string') return `${name}.label must be a string`
if (h.deeplink !== undefined && typeof h.deeplink !== 'string') return `${name}.deeplink must be a string`
return null
}
for (const [name, value] of [['wa', wa], ['telegram', telegram]]) {
const err = validateHandle(value, name)
if (err) return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: err } })
}
const config = await setSupportHandles({ wa, telegram })
await publishConfigInvalidate('support_handles_json')
return reply.send({ success: true, data: config })
})
}

View File

@@ -35,7 +35,10 @@ const resolveCustomer = async (request, reply) => {
}
export const clientChatRoutes = async (app) => {
// Get pricing tiers + free trial eligibility
// 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 })
@@ -171,7 +174,7 @@ export const clientChatRoutes = async (app) => {
/**
* Extension request REQUIRES `extension_payment_session_id`.
* The payment session must be is_extension=true and is_free_trial=false.
* 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) => {

View File

@@ -0,0 +1,60 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerById } from '../../services/customer.service.js'
import { isCustomerEligibleForFirstSessionDiscount } from '../../services/pricing.service.js'
import { getDb } from '../../db/client.js'
import { UserType, SessionStatus } from '../../constants.js'
const sql = getDb()
/**
* Phase 4 onboarding-state endpoint. Drives:
* - Verif Choice Sheet visibility on the post-name screen.
* - S6 paywall vs Pilih cara routing decision.
*
* Eligibility predicate (server-authoritative — client never decides):
* first_session_discount_enabled AND phone-verified AND no completed sessions.
*
* NOTE: deviates from the plan's `users.phone_verified_at` reference — there is no
* such column. `customers.phone IS NOT NULL` is equivalent in this schema (phone is
* only ever set by the OTP-verify path).
*/
export const clientOnboardingRoutes = async (app) => {
app.get('/onboarding-state', { 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 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' },
})
}
const isPhoneVerified = !!customer.phone
const [prior] = await sql`
SELECT id FROM chat_sessions
WHERE customer_id = ${customer.id}
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
LIMIT 1
`
const hasConsultedBefore = !!prior
// Use the same predicate the pricing endpoint uses, so the two stay in lock-step.
const isFirstSessionDiscountEligible = await isCustomerEligibleForFirstSessionDiscount(customer.id)
return reply.send({
success: true,
data: {
has_consulted_before: hasConsultedBefore,
is_phone_verified: isPhoneVerified,
is_first_session_discount_eligible: isFirstSessionDiscountEligible,
is_anonymous: !!customer.is_anonymous,
},
})
})
}

View File

@@ -7,11 +7,14 @@ import {
getPaymentSession,
} from '../../services/payment.service.js'
import {
isCustomerEligibleForFreeTrial,
isCustomerEligibleForFirstSessionDiscount,
isValidTier,
getPriceTiers,
findTier,
} from '../../services/pricing.service.js'
import { UserType } from '../../constants.js'
import { getDb } from '../../db/client.js'
import { UserType, SessionMode } from '../../constants.js'
const sql = getDb()
const resolveCustomer = async (request, reply) => {
if (request.auth?.userType !== UserType.CUSTOMER) {
@@ -30,6 +33,25 @@ const resolveCustomer = async (request, reply) => {
request.customer = customer
}
const readDiscountConfig = async () => {
const rows = await sql`
SELECT key, value FROM app_config
WHERE key IN (
'first_session_discount_enabled',
'first_session_discount_actual_price_idr',
'first_session_discount_duration_minutes',
'first_session_discount_modes'
)
`
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
return {
enabled: byKey.first_session_discount_enabled ?? true,
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
modes: byKey.first_session_discount_modes ?? ['chat'],
}
}
/**
* Payment session lifecycle (mocked — no Xendit yet).
*
@@ -39,12 +61,13 @@ const resolveCustomer = async (request, reply) => {
* 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.
// Create a payment session (status = pending). First-session-discount is server-authoritative:
// if the customer is eligible AND this is NOT an extension AND mode is in the configured
// modes list, amount is forced to the configured discount price.
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const {
duration_minutes,
mode = SessionMode.CHAT,
targeted_mitra_id = null,
is_extension = false,
} = request.body ?? {}
@@ -55,33 +78,44 @@ export const clientPaymentRoutes = async (app) => {
error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' },
})
}
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' },
})
}
// Free trial: never for extensions.
let isFreeTrial = false
let isFirstSessionDiscount = false
let amount
if (!is_extension) {
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
const eligible = await isCustomerEligibleForFirstSessionDiscount(request.customer.id)
if (eligible) {
isFreeTrial = true
amount = 0
const discount = await readDiscountConfig()
// Discount is mode-gated. With default config (modes: ['chat']) call-mode never
// gets the discount even if the user is eligible.
if (
discount.enabled
&& discount.modes.includes(mode)
&& duration_minutes === discount.duration_minutes
) {
isFirstSessionDiscount = true
amount = discount.actual_price_idr
}
}
}
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 (!isFirstSessionDiscount) {
// Resolve amount from the configured tier list for the requested mode.
const tier = await findTier({ mode, durationMinutes: duration_minutes })
if (!tier) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested duration' },
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested mode/duration' },
})
}
amount = tier.price
// Sanity check (defense-in-depth) — duration+price should match a known tier.
if (!(await isValidTier(duration_minutes, amount))) {
amount = tier.price_idr
if (!(await isValidTier({ mode, durationMinutes: duration_minutes, priceIdr: amount }))) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
@@ -93,9 +127,10 @@ export const clientPaymentRoutes = async (app) => {
customerId: request.customer.id,
durationMinutes: duration_minutes,
amount,
isFreeTrial,
isFirstSessionDiscount,
isExtension: Boolean(is_extension),
targetedMitraId: targeted_mitra_id || null,
mode,
})
return reply.code(201).send({
@@ -104,8 +139,9 @@ export const clientPaymentRoutes = async (app) => {
id: session.id,
amount: session.amount,
duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial,
is_first_session_discount: session.is_first_session_discount,
is_extension: session.is_extension,
mode: session.mode,
targeted_mitra_id: session.targeted_mitra_id,
expires_at: session.expires_at,
status: session.status,

View File

@@ -0,0 +1,14 @@
import { authenticate } from '../../plugins/auth.js'
import { getSupportHandles } from '../../services/config.service.js'
/**
* Phase 4 — Tanya Admin sheet handles. Sourced from `app_config.support_handles_json`,
* editable by CC. Authenticated so unauthenticated callers can't enumerate the
* support channels (rate-limit hardening, not a secret).
*/
export const clientSupportRoutes = async (app) => {
app.get('/support-handles', { preHandler: authenticate }, async (_request, reply) => {
const handles = await getSupportHandles()
return reply.send({ success: true, data: handles })
})
}

View File

@@ -0,0 +1,14 @@
import { getAuthProviders } from '../../services/auth-providers.service.js'
/**
* GET /api/shared/auth-providers — public, no auth required.
*
* Tells the client which auth entry points are wired up server-side. The client uses
* this to hide Google/Apple buttons when the corresponding OAuth env vars aren't
* configured (avoids a "press button → mysterious 500" UX).
*/
export const sharedAuthProvidersRoutes = async (app) => {
app.get('/', async (_request, reply) => {
return reply.send({ success: true, data: getAuthProviders() })
})
}