Phase 3 scaffold: chat engine (WebSocket, FCM, pricing, timer, extension, history)

- Backend: WebSocket plugin, chat/pricing/timer/extension/closure/notification services
- Client app: ChatBloc, pricing dialog, chat screen with message status, extension/goodbye flow, history
- Mitra app: MitraChatBloc, ExtensionBloc, chat screen, extension accept/reject, history
- Control center: free trial, extension timeout, early end config toggles
- DB migration: chat_messages, session_closures, session_extensions, customer_transactions tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:58:11 +08:00
parent 844d7234e6
commit b4efcf14c2
47 changed files with 4361 additions and 44 deletions

View File

@@ -1,6 +1,12 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
import { getAnonymityConfig, setAnonymityConfig, getMaxCustomersPerMitra, setMaxCustomersPerMitra } from '../../services/config.service.js'
import {
getAnonymityConfig, setAnonymityConfig,
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
getFreeTrialConfig, setFreeTrialConfig,
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
getEarlyEndConfig, setEarlyEndConfig,
} from '../../services/config.service.js'
const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
@@ -44,4 +50,55 @@ export const internalConfigRoutes = async (app) => {
const config = await setMaxCustomersPerMitra(max_customers_per_mitra)
return reply.send({ success: true, data: config })
})
// --- Phase 3: Free Trial ---
app.get('/free-trial', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const config = await getFreeTrialConfig()
return reply.send({ success: true, data: config })
})
app.patch('/free-trial', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { enabled, duration_minutes } = request.body ?? {}
const config = await setFreeTrialConfig({ enabled, duration_minutes })
return reply.send({ success: true, data: config })
})
// --- Phase 3: Extension Timeout ---
app.get('/extension-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const config = await getExtensionTimeoutConfig()
return reply.send({ success: true, data: config })
})
app.patch('/extension-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { extension_timeout_seconds } = request.body ?? {}
if (typeof extension_timeout_seconds !== 'number' || extension_timeout_seconds < 10) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'Must be a number >= 10' } })
}
const config = await setExtensionTimeoutConfig(extension_timeout_seconds)
return reply.send({ success: true, data: config })
})
// --- Phase 3: Early End ---
app.get('/early-end', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const config = await getEarlyEndConfig()
return reply.send({ success: true, data: config })
})
app.patch('/early-end', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { mitra_enabled, customer_enabled } = request.body ?? {}
const config = await setEarlyEndConfig({ mitra_enabled, customer_enabled })
return reply.send({ success: true, data: config })
})
}

View File

@@ -1,8 +1,10 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
import { createPairingRequest, cancelPairingRequest, getSessionStatus } from '../../services/pairing.service.js'
import { getActiveSessionByCustomer, endSession } from '../../services/session.service.js'
import { getActiveSessionByCustomer, endSession, getCustomerHistory } from '../../services/session.service.js'
import { subscribe } from '../../plugins/valkey.js'
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
import { requestExtension } from '../../services/extension.service.js'
const resolveCustomer = async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
@@ -16,8 +18,48 @@ const resolveCustomer = async (request, reply) => {
}
export const clientChatRoutes = async (app) => {
// Get pricing tiers + free trial eligibility
app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const pricing = await getPricingForCustomer(request.customer.id)
return reply.send({ success: true, data: pricing })
})
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await createPairingRequest(request.customer.id)
const { duration_minutes, price, is_free_trial } = request.body || {}
// 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,
})
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 (!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 })
return reply.code(201).send({ success: true, data: session })
})
@@ -72,4 +114,27 @@ export const clientChatRoutes = async (app) => {
const session = await endSession(request.params.sessionId, 'customer')
return reply.send({ success: true, data: session })
})
// Request session extension
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { duration_minutes, price } = request.body || {}
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 })
return reply.send({ success: true, data: extension })
})
// Chat history
app.get('/history', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { page, limit } = request.query
const history = await getCustomerHistory(request.customer.id, {
page: page ? parseInt(page) : 1,
limit: limit ? parseInt(limit) : 20,
})
return reply.send({ success: true, data: history })
})
}

View File

@@ -1,8 +1,9 @@
import { authenticate } from '../../plugins/auth.js'
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js'
import { getActiveSessionsByMitra, endSession } from '../../services/session.service.js'
import { getActiveSessionsByMitra, endSession, getMitraHistory } from '../../services/session.service.js'
import { subscribe } from '../../plugins/valkey.js'
import { respondToExtension } from '../../services/extension.service.js'
const resolveMitra = async (request, reply) => {
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
@@ -66,4 +67,27 @@ export const mitraChatRoutes = async (app) => {
const session = await endSession(request.params.sessionId, 'mitra')
return reply.send({ success: true, data: session })
})
// Respond to extension request
app.post('/sessions/:sessionId/extend-response', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const { extension_id, accepted } = request.body || {}
if (!extension_id || accepted === undefined) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'extension_id and accepted are required' },
})
}
const extension = await respondToExtension(extension_id, request.params.sessionId, request.mitra.id, accepted)
return reply.send({ success: true, data: extension })
})
// Chat history
app.get('/history', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const { page, limit } = request.query
const history = await getMitraHistory(request.mitra.id, {
page: page ? parseInt(page) : 1,
limit: limit ? parseInt(limit) : 20,
})
return reply.send({ success: true, data: history })
})
}

View File

@@ -0,0 +1,79 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
import { getMessages } from '../../services/chat.service.js'
import { getSessionClosures } from '../../services/closure.service.js'
import { registerDeviceToken } from '../../services/notification.service.js'
const resolveUser = async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
if (customer) {
request.userType = 'customer'
request.userId = customer.id
return
}
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
if (mitra) {
request.userType = 'mitra'
request.userId = mitra.id
return
}
return reply.code(404).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Account not found' },
})
}
export const sharedChatRoutes = async (app) => {
// Get messages for a session (paginated)
app.get('/chat/:sessionId/messages', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
const { sessionId } = request.params
const { limit, before } = request.query
const messages = await getMessages(sessionId, {
limit: limit ? parseInt(limit) : 50,
before,
})
return reply.send({ success: true, data: messages })
})
// Get session info
app.get('/chat/:sessionId/info', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
const { sessionId } = request.params
const { getSessionById } = await import('../../services/session.service.js')
const session = await getSessionById(sessionId)
if (!session) {
return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } })
}
return reply.send({ success: true, data: session })
})
// Get full transcript (read-only, for history)
app.get('/chat/:sessionId/transcript', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
const { sessionId } = request.params
const messages = await getMessages(sessionId, { limit: 10000 })
const closures = await getSessionClosures(sessionId)
return reply.send({ success: true, data: { messages, closures } })
})
// Register FCM device token
app.post('/device-token', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
const { token } = request.body
if (!token) {
return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'Token is required' } })
}
await registerDeviceToken(request.userType, request.userId, token)
return reply.send({ success: true })
})
// Submit goodbye/closure message
app.post('/sessions/:sessionId/close-message', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
const { sessionId } = request.params
const { message } = request.body
if (!message) {
return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'Message is required' } })
}
const { submitClosureMessage } = await import('../../services/closure.service.js')
const closure = await submitClosureMessage(sessionId, request.userType, request.userId, message)
return reply.send({ success: true, data: closure })
})
}