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:
106
backend/src/services/closure.service.js
Normal file
106
backend/src/services/closure.service.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { publish } from '../plugins/valkey.js'
|
||||
import { clearSessionTimer } from './session-timer.service.js'
|
||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
export const submitClosureMessage = async (sessionId, userType, userId, message) => {
|
||||
// Verify session is in closing or active state (for early end)
|
||||
const [session] = await sql`
|
||||
SELECT id, status FROM chat_sessions
|
||||
WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending')
|
||||
`
|
||||
if (!session) {
|
||||
throw Object.assign(new Error('Session not found or already completed'), {
|
||||
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
// Save closure message
|
||||
const [closure] = await sql`
|
||||
INSERT INTO session_closures (session_id, user_type, user_id, message)
|
||||
VALUES (${sessionId}, ${userType}, ${userId}, ${message})
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, session_id, user_type, message, created_at
|
||||
`
|
||||
|
||||
// Check if both parties have submitted
|
||||
const closures = await sql`
|
||||
SELECT user_type FROM session_closures WHERE session_id = ${sessionId}
|
||||
`
|
||||
const hasCustomer = closures.some((c) => c.user_type === 'customer')
|
||||
const hasMitra = closures.some((c) => c.user_type === 'mitra')
|
||||
|
||||
if (hasCustomer && hasMitra) {
|
||||
// Both submitted — complete the session
|
||||
await completeSession(sessionId)
|
||||
}
|
||||
|
||||
return closure
|
||||
}
|
||||
|
||||
export const completeSession = async (sessionId) => {
|
||||
clearSessionTimer(sessionId)
|
||||
|
||||
const [session] = await sql`
|
||||
UPDATE chat_sessions
|
||||
SET status = 'completed', ended_at = NOW(), ended_by = 'system'
|
||||
WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending')
|
||||
RETURNING id, customer_id, mitra_id, status, ended_at
|
||||
`
|
||||
if (!session) return null
|
||||
|
||||
// Notify both parties
|
||||
const data = { type: 'session_completed', session_id: sessionId }
|
||||
sendToSessionParticipant(sessionId, 'customer', data)
|
||||
sendToSessionParticipant(sessionId, 'mitra', data)
|
||||
|
||||
await publish(`session:${sessionId}:status`, { type: 'session_ended', session_id: sessionId })
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export const initiateEarlyEnd = async (sessionId, userType) => {
|
||||
// Check if early end is enabled for this user type
|
||||
const configKey = userType === 'mitra' ? 'early_end_mitra_enabled' : 'early_end_customer_enabled'
|
||||
const [configRow] = await sql`SELECT value FROM app_config WHERE key = ${configKey}`
|
||||
const enabled = configRow?.value?.value ?? false
|
||||
|
||||
if (!enabled) {
|
||||
throw Object.assign(new Error('Early end is not enabled'), {
|
||||
code: 'EARLY_END_DISABLED', statusCode: 403,
|
||||
})
|
||||
}
|
||||
|
||||
// Move session to closing
|
||||
const [session] = await sql`
|
||||
UPDATE chat_sessions
|
||||
SET status = 'closing', ended_by = ${userType}
|
||||
WHERE id = ${sessionId} AND status = 'active'
|
||||
RETURNING id, customer_id, mitra_id
|
||||
`
|
||||
if (!session) {
|
||||
throw Object.assign(new Error('Session not active'), {
|
||||
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
clearSessionTimer(sessionId)
|
||||
|
||||
// Notify both parties to enter closure flow
|
||||
const data = { type: 'session_closing', session_id: sessionId, ended_by: userType }
|
||||
sendToSessionParticipant(sessionId, 'customer', data)
|
||||
sendToSessionParticipant(sessionId, 'mitra', data)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export const getSessionClosures = async (sessionId) => {
|
||||
return sql`
|
||||
SELECT user_type, message, created_at
|
||||
FROM session_closures
|
||||
WHERE session_id = ${sessionId}
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
}
|
||||
Reference in New Issue
Block a user