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,8 @@
import { getDb } from '../db/client.js'
import { getMaxCustomersPerMitra } from './config.service.js'
import { publish } from '../plugins/valkey.js'
import { startSessionTimer } from './session-timer.service.js'
import { startSessionListener } from './chat-handler.service.js'
const sql = getDb()
@@ -23,7 +25,7 @@ export const findAvailableMitras = async () => {
return mitras
}
export const createPairingRequest = async (customerId) => {
export const createPairingRequest = async (customerId, { duration_minutes, price, is_free_trial } = {}) => {
// Check for existing active session or request
const [existing] = await sql`
SELECT id, status FROM chat_sessions
@@ -43,11 +45,11 @@ export const createPairingRequest = async (customerId) => {
})
}
// Create session
// Create session with duration/price
const [session] = await sql`
INSERT INTO chat_sessions (customer_id, status)
VALUES (${customerId}, 'pending_acceptance')
RETURNING id, customer_id, status, created_at
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial)
VALUES (${customerId}, 'pending_acceptance', ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false})
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, created_at
`
// Create notifications for all available mitras
@@ -111,13 +113,35 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
pairingTimeouts.delete(sessionId)
}
// Auto-skip payment for now: move to active
// Auto-skip payment for now: move to active and set expires_at
const [activeSession] = await sql`
UPDATE chat_sessions SET status = 'active'
UPDATE chat_sessions
SET status = 'active',
expires_at = CASE
WHEN duration_minutes IS NOT NULL THEN NOW() + (duration_minutes || ' minutes')::interval
ELSE NULL
END
WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status, paired_at
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at
`
// Record transaction
if (activeSession.duration_minutes) {
const txType = activeSession.is_free_trial ? 'free_trial' : 'paid'
await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
`
}
// Start session timer if duration is set
if (activeSession.expires_at) {
startSessionTimer(sessionId, activeSession.expires_at)
}
// Start chat message listener for this session
startSessionListener(sessionId)
// Get mitra display name for customer notification
const [mitra] = await sql`
SELECT display_name FROM mitras WHERE id = ${mitraId}