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:
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
60
backend/src/routes/public/client.onboarding.routes.js
Normal file
60
backend/src/routes/public/client.onboarding.routes.js
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
14
backend/src/routes/public/client.support.routes.js
Normal file
14
backend/src/routes/public/client.support.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
14
backend/src/routes/public/shared.auth-providers.routes.js
Normal file
14
backend/src/routes/public/shared.auth-providers.routes.js
Normal 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() })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user