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

@@ -0,0 +1,159 @@
import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
import { sendToSessionParticipant } from '../plugins/websocket.js'
import { extendSessionTimer } from './session-timer.service.js'
const sql = getDb()
// Extension timeout map: extensionId → timeoutId
const extensionTimeouts = new Map()
const getExtensionTimeout = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'`
return (row?.value?.value ?? 60) * 1000 // Convert to ms
}
export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => {
// Verify session belongs to customer and just expired
const [session] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
WHERE id = ${sessionId} AND customer_id = ${customerId}
`
if (!session) {
throw Object.assign(new Error('Session not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
// Create extension record
const [extension] = await sql`
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status)
VALUES (${sessionId}, ${duration_minutes}, ${price}, 'pending')
RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at
`
// Pause the session
await sql`UPDATE chat_sessions SET status = 'extending' WHERE id = ${sessionId}`
// Notify mitra
sendToSessionParticipant(sessionId, 'mitra', {
type: 'extension_request',
extension_id: extension.id,
session_id: sessionId,
duration_minutes,
price,
})
// Notify customer that chat is paused
sendToSessionParticipant(sessionId, 'customer', {
type: 'session_paused',
session_id: sessionId,
reason: 'extension_pending',
})
// Start timeout
const timeoutMs = await getExtensionTimeout()
const timeoutId = setTimeout(async () => {
try {
await timeoutExtension(extension.id, sessionId)
} catch (_) {}
}, timeoutMs)
extensionTimeouts.set(extension.id, timeoutId)
return extension
}
export const respondToExtension = async (extensionId, sessionId, mitraId, accepted) => {
const status = accepted ? 'accepted' : 'rejected'
const [extension] = await sql`
UPDATE session_extensions
SET status = ${status}, responded_at = NOW()
WHERE id = ${extensionId} AND status = 'pending'
RETURNING id, session_id, requested_duration_minutes, requested_price, status
`
if (!extension) {
throw Object.assign(new Error('Extension not found or already resolved'), {
code: 'EXTENSION_RESOLVED', statusCode: 409,
})
}
// Clear timeout
const timeoutId = extensionTimeouts.get(extensionId)
if (timeoutId) {
clearTimeout(timeoutId)
extensionTimeouts.delete(extensionId)
}
if (accepted) {
// Extend the session
await extendSessionTimer(extension.session_id, extension.requested_duration_minutes)
// Resume session
await sql`UPDATE chat_sessions SET status = 'active' WHERE id = ${extension.session_id}`
// Record transaction
await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
SELECT customer_id, id, 'extension', ${extension.requested_price}
FROM chat_sessions WHERE id = ${extension.session_id}
`
// Notify both parties
sendToSessionParticipant(sessionId, 'customer', {
type: 'extension_response',
accepted: true,
duration_minutes: extension.requested_duration_minutes,
})
sendToSessionParticipant(sessionId, 'mitra', {
type: 'session_resumed',
session_id: sessionId,
})
} else {
// Rejected — proceed to closure
await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}`
sendToSessionParticipant(sessionId, 'customer', {
type: 'extension_response',
accepted: false,
})
sendToSessionParticipant(sessionId, 'mitra', {
type: 'session_closing',
session_id: sessionId,
})
sendToSessionParticipant(sessionId, 'customer', {
type: 'session_closing',
session_id: sessionId,
})
}
return extension
}
const timeoutExtension = async (extensionId, sessionId) => {
extensionTimeouts.delete(extensionId)
const [extension] = await sql`
UPDATE session_extensions
SET status = 'timeout', responded_at = NOW()
WHERE id = ${extensionId} AND status = 'pending'
RETURNING id, session_id
`
if (!extension) return
// Timeout = proceed to closure
await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}`
sendToSessionParticipant(sessionId, 'customer', {
type: 'extension_response',
accepted: false,
reason: 'timeout',
})
sendToSessionParticipant(sessionId, 'mitra', {
type: 'session_closing',
session_id: sessionId,
})
sendToSessionParticipant(sessionId, 'customer', {
type: 'session_closing',
session_id: sessionId,
})
}