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:
159
backend/src/services/extension.service.js
Normal file
159
backend/src/services/extension.service.js
Normal 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user