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

@@ -136,7 +136,7 @@ const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { al
/**
* General-blast pairing request. Requires a confirmed payment_session_id.
*
* The duration_minutes / price / is_free_trial values for the chat_session row are
* The duration_minutes / price / is_first_session_discount values for the chat_session row are
* sourced from the payment session — the client does not dictate pricing here.
*
* `allowTargetedPayment` is set true by the fallback-to-blast path: the original payment
@@ -183,14 +183,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
// Create session sourced from the payment session.
const [session] = await sql`
INSERT INTO chat_sessions (
customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
)
VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId}
)
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
`
// Fan out to all available mitras in parallel — DB inserts and notifications are
@@ -206,7 +206,7 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
request_type: PairingRequestType.GENERAL,
created_at: session.created_at,
duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial,
is_first_session_discount: session.is_first_session_discount,
topic_sensitivity: session.topic_sensitivity,
})
}))
@@ -305,14 +305,14 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
// Create session sourced from the payment session, status = pending_acceptance.
const [session] = await sql`
INSERT INTO chat_sessions (
customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
)
VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId}
)
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
`
// Single notification to the targeted mitra
@@ -330,7 +330,7 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
request_type: PairingRequestType.RETURNING,
created_at: session.created_at,
duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial,
is_first_session_discount: session.is_first_session_discount,
topic_sensitivity: session.topic_sensitivity,
confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds,
})
@@ -399,12 +399,12 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
ELSE NULL
END
WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at, payment_session_id
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_session_id
`
// Record transaction
if (activeSession.duration_minutes) {
const txType = activeSession.is_free_trial ? TransactionType.FREE_TRIAL : TransactionType.PAID
const txType = activeSession.is_first_session_discount ? TransactionType.FIRST_SESSION_DISCOUNT : TransactionType.PAID
await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
@@ -787,7 +787,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
SELECT
cs.id AS session_id,
cs.duration_minutes,
cs.is_free_trial,
cs.is_first_session_discount,
cs.topic_sensitivity,
cs.created_at,
CASE