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:
@@ -8,22 +8,29 @@ import { sharedConfigRoutes } from './routes/public/shared.config.routes.js'
|
||||
import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js'
|
||||
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
||||
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
||||
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
||||
import { errorHandler } from './plugins/error-handler.js'
|
||||
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
|
||||
|
||||
export const buildPublicApp = async () => {
|
||||
const app = Fastify({ logger: true })
|
||||
|
||||
await app.register(cors, { origin: true })
|
||||
await app.register(sensible)
|
||||
await registerWebSocketPlugin(app)
|
||||
app.setErrorHandler(errorHandler)
|
||||
|
||||
app.register(customerRoutes, { prefix: '/api/shared/customer' })
|
||||
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
|
||||
app.register(sharedChatRoutes, { prefix: '/api/shared' })
|
||||
app.register(clientAuthRoutes, { prefix: '/api/client/auth' })
|
||||
app.register(mitraAuthRoutes, { prefix: '/api/mitra/auth' })
|
||||
app.register(mitraStatusRoutes, { prefix: '/api/mitra/status' })
|
||||
app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' })
|
||||
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
|
||||
|
||||
// WebSocket route (registered at app level, not prefixed)
|
||||
registerWebSocketRoute(app)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -142,6 +142,125 @@ const migrate = async () => {
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
// --- Phase 3: Chat Engine ---
|
||||
|
||||
// Add session duration/pricing columns to chat_sessions
|
||||
await sql`
|
||||
ALTER TABLE chat_sessions
|
||||
ADD COLUMN IF NOT EXISTS duration_minutes INT,
|
||||
ADD COLUMN IF NOT EXISTS price INT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS is_free_trial BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS extended_minutes INT NOT NULL DEFAULT 0
|
||||
`
|
||||
|
||||
// Add FCM token columns
|
||||
await sql`
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
|
||||
`
|
||||
|
||||
await sql`
|
||||
ALTER TABLE mitras
|
||||
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||||
sender_type VARCHAR(10) NOT NULL,
|
||||
sender_id UUID NOT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'text',
|
||||
content TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'sent',
|
||||
delivered_at TIMESTAMPTZ,
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created
|
||||
ON chat_messages (session_id, created_at)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_status
|
||||
ON chat_messages (session_id, status)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS session_closures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||||
user_type VARCHAR(10) NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS session_extensions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||||
requested_duration_minutes INT NOT NULL,
|
||||
requested_price INT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
responded_at TIMESTAMPTZ
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS customer_transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||||
type VARCHAR(20) NOT NULL,
|
||||
amount INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_customer_transactions_customer_id
|
||||
ON customer_transactions (customer_id)
|
||||
`
|
||||
|
||||
// Phase 3 config keys
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value)
|
||||
VALUES ('free_trial_enabled', '{"value": true}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value)
|
||||
VALUES ('free_trial_duration_minutes', '{"value": 5}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value)
|
||||
VALUES ('extension_timeout_seconds', '{"value": 60}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value)
|
||||
VALUES ('early_end_mitra_enabled', '{"value": false}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value)
|
||||
VALUES ('early_end_customer_enabled', '{"value": false}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
console.log('Migration complete.')
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
151
backend/src/plugins/websocket.js
Normal file
151
backend/src/plugins/websocket.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import websocket from '@fastify/websocket'
|
||||
import { verifyFirebaseToken } from './firebase.js'
|
||||
import { getCustomerByFirebaseUid } from '../services/customer.service.js'
|
||||
import { getMitraByFirebaseUid } from '../services/mitra.service.js'
|
||||
import { subscribe, publish } from './valkey.js'
|
||||
|
||||
// Track active WebSocket connections: sessionId → { customer, mitra }
|
||||
const sessionConnections = new Map()
|
||||
|
||||
// Track user → socket mapping for FCM fallback detection
|
||||
const userSockets = new Map() // `customer:${id}` or `mitra:${id}` → socket
|
||||
|
||||
export const registerWebSocketPlugin = async (app) => {
|
||||
await app.register(websocket)
|
||||
}
|
||||
|
||||
export const isUserOnlineWs = (userType, userId) => {
|
||||
const key = `${userType}:${userId}`
|
||||
const socket = userSockets.get(key)
|
||||
return socket && socket.readyState === 1 // WebSocket.OPEN
|
||||
}
|
||||
|
||||
export const getSessionConnections = (sessionId) => {
|
||||
return sessionConnections.get(sessionId) || {}
|
||||
}
|
||||
|
||||
const sendToSocket = (socket, data) => {
|
||||
if (socket && socket.readyState === 1) {
|
||||
socket.send(JSON.stringify(data))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const sendToSessionParticipant = (sessionId, userType, data) => {
|
||||
const conns = sessionConnections.get(sessionId)
|
||||
if (!conns) return false
|
||||
return sendToSocket(conns[userType], data)
|
||||
}
|
||||
|
||||
export const sendToUser = (userType, userId, data) => {
|
||||
const key = `${userType}:${userId}`
|
||||
const socket = userSockets.get(key)
|
||||
return sendToSocket(socket, data)
|
||||
}
|
||||
|
||||
export const registerWebSocketRoute = (app) => {
|
||||
app.get('/api/shared/ws', { websocket: true }, (socket, request) => {
|
||||
let authenticatedUser = null // { type: 'customer'|'mitra', id, sessionId }
|
||||
let valkeyUnsubscribes = []
|
||||
|
||||
const send = (data) => sendToSocket(socket, data)
|
||||
|
||||
socket.on('message', async (raw) => {
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(raw.toString())
|
||||
} catch {
|
||||
send({ type: 'error', message: 'Invalid JSON' })
|
||||
return
|
||||
}
|
||||
|
||||
// Handle auth message
|
||||
if (msg.type === 'auth') {
|
||||
try {
|
||||
const decoded = await verifyFirebaseToken(msg.token)
|
||||
const customer = await getCustomerByFirebaseUid(decoded.uid)
|
||||
const mitra = customer ? null : await getMitraByFirebaseUid(decoded.uid)
|
||||
|
||||
if (!customer && !mitra) {
|
||||
send({ type: 'error', message: 'Account not found' })
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
|
||||
const userType = customer ? 'customer' : 'mitra'
|
||||
const userId = customer ? customer.id : mitra.id
|
||||
const sessionId = msg.session_id
|
||||
|
||||
authenticatedUser = { type: userType, id: userId, sessionId }
|
||||
|
||||
// Register in connection maps
|
||||
const userKey = `${userType}:${userId}`
|
||||
userSockets.set(userKey, socket)
|
||||
|
||||
if (sessionId) {
|
||||
if (!sessionConnections.has(sessionId)) {
|
||||
sessionConnections.set(sessionId, {})
|
||||
}
|
||||
sessionConnections.get(sessionId)[userType] = socket
|
||||
}
|
||||
|
||||
// Subscribe to session channel for events from other services
|
||||
if (sessionId) {
|
||||
const unsub = subscribe(`session:${sessionId}:chat`, (data) => {
|
||||
// Don't echo messages back to sender
|
||||
if (data._sender_type === userType && data._sender_id === userId) return
|
||||
const { _sender_type, _sender_id, ...payload } = data
|
||||
send(payload)
|
||||
})
|
||||
valkeyUnsubscribes.push(unsub)
|
||||
}
|
||||
|
||||
send({ type: 'auth_ok', user_type: userType, user_id: userId })
|
||||
} catch (err) {
|
||||
send({ type: 'error', message: 'Authentication failed' })
|
||||
socket.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// All other messages require authentication
|
||||
if (!authenticatedUser) {
|
||||
send({ type: 'error', message: 'Not authenticated. Send auth message first.' })
|
||||
return
|
||||
}
|
||||
|
||||
// Route message types to handlers via Valkey pub/sub
|
||||
const { type, ...payload } = msg
|
||||
await publish(`session:${authenticatedUser.sessionId}:incoming`, {
|
||||
type,
|
||||
...payload,
|
||||
_sender_type: authenticatedUser.type,
|
||||
_sender_id: authenticatedUser.id,
|
||||
_session_id: authenticatedUser.sessionId,
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('close', () => {
|
||||
if (authenticatedUser) {
|
||||
const userKey = `${authenticatedUser.type}:${authenticatedUser.id}`
|
||||
userSockets.delete(userKey)
|
||||
|
||||
if (authenticatedUser.sessionId) {
|
||||
const conns = sessionConnections.get(authenticatedUser.sessionId)
|
||||
if (conns) {
|
||||
delete conns[authenticatedUser.type]
|
||||
if (!conns.customer && !conns.mitra) {
|
||||
sessionConnections.delete(authenticatedUser.sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up Valkey subscriptions
|
||||
for (const unsub of valkeyUnsubscribes) {
|
||||
unsub()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
79
backend/src/routes/public/shared.chat.routes.js
Normal file
79
backend/src/routes/public/shared.chat.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { buildPublicApp } from './app.public.js'
|
||||
import { buildInternalApp } from './app.internal.js'
|
||||
import { autoOfflineStaleMitras } from './services/mitra-status.service.js'
|
||||
import { initFirebase } from './plugins/firebase.js'
|
||||
import { restoreActiveTimers } from './services/session-timer.service.js'
|
||||
|
||||
const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000
|
||||
const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001
|
||||
@@ -19,6 +20,9 @@ const start = async () => {
|
||||
await internalApp.listen({ port: INTERNAL_PORT, host: INTERNAL_HOST })
|
||||
console.log(`Internal API listening on ${INTERNAL_HOST}:${INTERNAL_PORT}`)
|
||||
|
||||
// Restore session timers for active sessions (on server restart)
|
||||
await restoreActiveTimers()
|
||||
|
||||
// Auto-offline mitras with stale heartbeat (every 30s)
|
||||
setInterval(async () => {
|
||||
try {
|
||||
|
||||
82
backend/src/services/chat-handler.service.js
Normal file
82
backend/src/services/chat-handler.service.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { subscribe } from '../plugins/valkey.js'
|
||||
import { sendMessage, markDelivered, markRead } from './chat.service.js'
|
||||
import { initiateEarlyEnd } from './closure.service.js'
|
||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||
|
||||
// Track typing throttle per session+user
|
||||
const typingLastSent = new Map()
|
||||
const TYPING_THROTTLE_MS = 2000
|
||||
|
||||
// Active session listeners: sessionId → unsubscribe
|
||||
const sessionListeners = new Map()
|
||||
|
||||
export const startSessionListener = (sessionId) => {
|
||||
if (sessionListeners.has(sessionId)) return
|
||||
|
||||
const unsub = subscribe(`session:${sessionId}:incoming`, async (data) => {
|
||||
const { type, _sender_type, _sender_id, _session_id, ...payload } = data
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'message':
|
||||
await sendMessage({
|
||||
sessionId: _session_id,
|
||||
senderType: _sender_type,
|
||||
senderId: _sender_id,
|
||||
content: payload.content,
|
||||
type: payload.message_type || 'text',
|
||||
})
|
||||
break
|
||||
|
||||
case 'typing':
|
||||
handleTyping(_session_id, _sender_type)
|
||||
break
|
||||
|
||||
case 'delivered':
|
||||
await markDelivered(_session_id, _sender_type, payload.message_ids)
|
||||
break
|
||||
|
||||
case 'read':
|
||||
await markRead(_session_id, _sender_type, payload.message_ids)
|
||||
break
|
||||
|
||||
case 'early_end':
|
||||
await initiateEarlyEnd(_session_id, _sender_type)
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[chat-handler] Error processing ${type}:`, err.message)
|
||||
sendToSessionParticipant(_session_id, _sender_type, {
|
||||
type: 'error',
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
sessionListeners.set(sessionId, unsub)
|
||||
}
|
||||
|
||||
export const stopSessionListener = (sessionId) => {
|
||||
const unsub = sessionListeners.get(sessionId)
|
||||
if (unsub) {
|
||||
unsub()
|
||||
sessionListeners.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTyping = (sessionId, senderType) => {
|
||||
const key = `${sessionId}:${senderType}`
|
||||
const now = Date.now()
|
||||
const lastSent = typingLastSent.get(key) || 0
|
||||
|
||||
if (now - lastSent < TYPING_THROTTLE_MS) return
|
||||
|
||||
typingLastSent.set(key, now)
|
||||
|
||||
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
||||
sendToSessionParticipant(sessionId, recipientType, {
|
||||
type: 'typing',
|
||||
sender_type: senderType,
|
||||
})
|
||||
}
|
||||
127
backend/src/services/chat.service.js
Normal file
127
backend/src/services/chat.service.js
Normal file
@@ -0,0 +1,127 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { publish } from '../plugins/valkey.js'
|
||||
import { isUserOnlineWs, sendToSessionParticipant } from '../plugins/websocket.js'
|
||||
import { sendPushNotification } from './notification.service.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
export const sendMessage = async ({ sessionId, senderType, senderId, content, type = 'text' }) => {
|
||||
// Verify session is active
|
||||
const [session] = await sql`
|
||||
SELECT id, customer_id, mitra_id, status FROM chat_sessions
|
||||
WHERE id = ${sessionId} AND status = 'active'
|
||||
`
|
||||
if (!session) {
|
||||
throw Object.assign(new Error('Session is not active'), {
|
||||
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
// Save message
|
||||
const [message] = await sql`
|
||||
INSERT INTO chat_messages (session_id, sender_type, sender_id, type, content, status)
|
||||
VALUES (${sessionId}, ${senderType}, ${senderId}, ${type}, ${content}, 'sent')
|
||||
RETURNING id, session_id, sender_type, sender_id, type, content, status, created_at
|
||||
`
|
||||
|
||||
// Send ack to sender
|
||||
sendToSessionParticipant(sessionId, senderType, {
|
||||
type: 'message_ack',
|
||||
message_id: message.id,
|
||||
status: 'sent',
|
||||
created_at: message.created_at,
|
||||
})
|
||||
|
||||
// Determine recipient
|
||||
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
||||
const recipientId = senderType === 'customer' ? session.mitra_id : session.customer_id
|
||||
|
||||
// Try to send via WebSocket
|
||||
const delivered = sendToSessionParticipant(sessionId, recipientType, {
|
||||
type: 'message',
|
||||
message_id: message.id,
|
||||
sender_type: senderType,
|
||||
content: message.content,
|
||||
message_type: message.type,
|
||||
created_at: message.created_at,
|
||||
})
|
||||
|
||||
// If recipient not connected via WebSocket, send FCM push
|
||||
if (!delivered && recipientId) {
|
||||
await sendPushNotification(recipientType, recipientId, {
|
||||
title: senderType === 'customer' ? 'Pesan baru dari Customer' : 'Pesan baru dari Bestie',
|
||||
body: content.length > 100 ? content.substring(0, 100) + '...' : content,
|
||||
data: { session_id: sessionId, type: 'chat_message' },
|
||||
})
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
export const markDelivered = async (sessionId, senderType, messageIds) => {
|
||||
if (!messageIds || messageIds.length === 0) return
|
||||
|
||||
await sql`
|
||||
UPDATE chat_messages
|
||||
SET status = 'delivered', delivered_at = NOW()
|
||||
WHERE id = ANY(${messageIds})
|
||||
AND session_id = ${sessionId}
|
||||
AND status = 'sent'
|
||||
`
|
||||
|
||||
// Notify sender about delivery
|
||||
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
||||
sendToSessionParticipant(sessionId, recipientType, {
|
||||
type: 'message_status',
|
||||
message_ids: messageIds,
|
||||
status: 'delivered',
|
||||
})
|
||||
}
|
||||
|
||||
export const markRead = async (sessionId, senderType, messageIds) => {
|
||||
if (!messageIds || messageIds.length === 0) return
|
||||
|
||||
await sql`
|
||||
UPDATE chat_messages
|
||||
SET status = 'read', read_at = NOW()
|
||||
WHERE id = ANY(${messageIds})
|
||||
AND session_id = ${sessionId}
|
||||
AND status IN ('sent', 'delivered')
|
||||
`
|
||||
|
||||
// Notify sender about read
|
||||
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
||||
sendToSessionParticipant(sessionId, recipientType, {
|
||||
type: 'message_status',
|
||||
message_ids: messageIds,
|
||||
status: 'read',
|
||||
})
|
||||
}
|
||||
|
||||
export const getMessages = async (sessionId, { limit = 50, before } = {}) => {
|
||||
const conditions = before
|
||||
? sql`AND created_at < ${before}`
|
||||
: sql``
|
||||
|
||||
const messages = await sql`
|
||||
SELECT id, session_id, sender_type, sender_id, type, content, status, delivered_at, read_at, created_at
|
||||
FROM chat_messages
|
||||
WHERE session_id = ${sessionId}
|
||||
${conditions}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit}
|
||||
`
|
||||
return messages.reverse() // Return in chronological order
|
||||
}
|
||||
|
||||
export const getUndeliveredMessages = async (sessionId, recipientType) => {
|
||||
const senderType = recipientType === 'customer' ? 'mitra' : 'customer'
|
||||
return sql`
|
||||
SELECT id, session_id, sender_type, sender_id, type, content, status, created_at
|
||||
FROM chat_messages
|
||||
WHERE session_id = ${sessionId}
|
||||
AND sender_type = ${senderType}
|
||||
AND status = 'sent'
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
}
|
||||
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
|
||||
`
|
||||
}
|
||||
@@ -29,3 +29,73 @@ export const setMaxCustomersPerMitra = async (value) => {
|
||||
`
|
||||
return { max_customers_per_mitra: value }
|
||||
}
|
||||
|
||||
// --- Phase 3 config ---
|
||||
|
||||
export const getFreeTrialConfig = async () => {
|
||||
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
||||
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
||||
return {
|
||||
enabled: enabledRow?.value?.value ?? false,
|
||||
duration_minutes: durationRow?.value?.value ?? 5,
|
||||
}
|
||||
}
|
||||
|
||||
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||
if (enabled !== undefined) {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('free_trial_enabled', ${sql.json({ value: enabled })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
if (duration_minutes !== undefined) {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('free_trial_duration_minutes', ${sql.json({ value: duration_minutes })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
return getFreeTrialConfig()
|
||||
}
|
||||
|
||||
export const getExtensionTimeoutConfig = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'`
|
||||
return { extension_timeout_seconds: row?.value?.value ?? 60 }
|
||||
}
|
||||
|
||||
export const setExtensionTimeoutConfig = async (seconds) => {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('extension_timeout_seconds', ${sql.json({ value: seconds })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
return { extension_timeout_seconds: seconds }
|
||||
}
|
||||
|
||||
export const getEarlyEndConfig = async () => {
|
||||
const [mitraRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_mitra_enabled'`
|
||||
const [customerRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_customer_enabled'`
|
||||
return {
|
||||
mitra_enabled: mitraRow?.value?.value ?? false,
|
||||
customer_enabled: customerRow?.value?.value ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => {
|
||||
if (mitra_enabled !== undefined) {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('early_end_mitra_enabled', ${sql.json({ value: mitra_enabled })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
if (customer_enabled !== undefined) {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('early_end_customer_enabled', ${sql.json({ value: customer_enabled })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
return getEarlyEndConfig()
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
52
backend/src/services/notification.service.js
Normal file
52
backend/src/services/notification.service.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import admin from 'firebase-admin'
|
||||
import { getDb } from '../db/client.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
export const registerDeviceToken = async (userType, userId, fcmToken) => {
|
||||
const table = userType === 'customer' ? 'customers' : 'mitras'
|
||||
await sql`
|
||||
UPDATE ${sql(table)}
|
||||
SET fcm_token = ${fcmToken}
|
||||
WHERE id = ${userId}
|
||||
`
|
||||
}
|
||||
|
||||
export const sendPushNotification = async (recipientType, recipientId, { title, body, data = {} }) => {
|
||||
const table = recipientType === 'customer' ? 'customers' : 'mitras'
|
||||
const [user] = await sql`
|
||||
SELECT fcm_token FROM ${sql(table)} WHERE id = ${recipientId}
|
||||
`
|
||||
|
||||
if (!user?.fcm_token) return false
|
||||
|
||||
try {
|
||||
await admin.messaging().send({
|
||||
token: user.fcm_token,
|
||||
notification: { title, body },
|
||||
data: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(data).map(([k, v]) => [k, String(v)])
|
||||
),
|
||||
click_action: 'FLUTTER_NOTIFICATION_CLICK',
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: { channelId: 'chat_messages' },
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: { sound: 'default', badge: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(`[FCM] Failed to send to ${recipientType}:${recipientId}:`, err.message)
|
||||
// Clear invalid token
|
||||
if (err.code === 'messaging/registration-token-not-registered') {
|
||||
await sql`UPDATE ${sql(table)} SET fcm_token = NULL WHERE id = ${recipientId}`
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
54
backend/src/services/pricing.service.js
Normal file
54
backend/src/services/pricing.service.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// Mock price tiers (will come from Control Center config later)
|
||||
const PRICE_TIERS = [
|
||||
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
|
||||
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
|
||||
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
|
||||
{ duration_minutes: 60, price: 150000, label: '60 Menit' },
|
||||
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
|
||||
]
|
||||
|
||||
export const getPriceTiers = () => PRICE_TIERS
|
||||
|
||||
export const isValidTier = (durationMinutes, price) => {
|
||||
return PRICE_TIERS.some(
|
||||
(t) => t.duration_minutes === durationMinutes && t.price === price
|
||||
)
|
||||
}
|
||||
|
||||
export const getFreeTrial = async () => {
|
||||
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
||||
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
||||
return {
|
||||
enabled: enabledRow?.value?.value ?? false,
|
||||
duration_minutes: durationRow?.value?.value ?? 5,
|
||||
}
|
||||
}
|
||||
|
||||
export const isCustomerEligibleForFreeTrial = async (customerId) => {
|
||||
const freeTrial = await getFreeTrial()
|
||||
if (!freeTrial.enabled) return false
|
||||
|
||||
const [tx] = await sql`
|
||||
SELECT id FROM customer_transactions
|
||||
WHERE customer_id = ${customerId}
|
||||
LIMIT 1
|
||||
`
|
||||
return !tx // Eligible only if no transactions at all
|
||||
}
|
||||
|
||||
export const getPricingForCustomer = async (customerId) => {
|
||||
const tiers = getPriceTiers()
|
||||
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
|
||||
const freeTrial = await getFreeTrial()
|
||||
|
||||
return {
|
||||
tiers,
|
||||
free_trial: freeTrialEligible
|
||||
? { eligible: true, duration_minutes: freeTrial.duration_minutes }
|
||||
: { eligible: false },
|
||||
}
|
||||
}
|
||||
100
backend/src/services/session-timer.service.js
Normal file
100
backend/src/services/session-timer.service.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { publish } from '../plugins/valkey.js'
|
||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// Active session timers: sessionId → { warningTimeout, expiryTimeout }
|
||||
const sessionTimers = new Map()
|
||||
|
||||
export const startSessionTimer = (sessionId, expiresAt) => {
|
||||
const now = Date.now()
|
||||
const expiresMs = new Date(expiresAt).getTime()
|
||||
const warningMs = expiresMs - 60_000 // 1 minute before expiry
|
||||
|
||||
// Clear any existing timers
|
||||
clearSessionTimer(sessionId)
|
||||
|
||||
const timers = {}
|
||||
|
||||
// Warning timer (1 min before expiry)
|
||||
if (warningMs > now) {
|
||||
timers.warningTimeout = setTimeout(() => {
|
||||
onSessionWarning(sessionId)
|
||||
}, warningMs - now)
|
||||
}
|
||||
|
||||
// Expiry timer
|
||||
if (expiresMs > now) {
|
||||
timers.expiryTimeout = setTimeout(() => {
|
||||
onSessionExpired(sessionId)
|
||||
}, expiresMs - now)
|
||||
} else {
|
||||
// Already expired
|
||||
onSessionExpired(sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
sessionTimers.set(sessionId, timers)
|
||||
}
|
||||
|
||||
export const clearSessionTimer = (sessionId) => {
|
||||
const timers = sessionTimers.get(sessionId)
|
||||
if (timers) {
|
||||
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
|
||||
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
|
||||
sessionTimers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
export const extendSessionTimer = async (sessionId, additionalMinutes) => {
|
||||
const [session] = await sql`
|
||||
UPDATE chat_sessions
|
||||
SET expires_at = expires_at + ${additionalMinutes + ' minutes'}::interval,
|
||||
extended_minutes = extended_minutes + ${additionalMinutes}
|
||||
WHERE id = ${sessionId}
|
||||
RETURNING id, expires_at
|
||||
`
|
||||
if (session) {
|
||||
startSessionTimer(sessionId, session.expires_at)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
const onSessionWarning = (sessionId) => {
|
||||
const data = { type: 'session_timer', remaining_seconds: 60, session_id: sessionId }
|
||||
sendToSessionParticipant(sessionId, 'customer', data)
|
||||
sendToSessionParticipant(sessionId, 'mitra', data)
|
||||
}
|
||||
|
||||
const onSessionExpired = async (sessionId) => {
|
||||
clearSessionTimer(sessionId)
|
||||
|
||||
// Check session is still active
|
||||
const [session] = await sql`
|
||||
SELECT id, status FROM chat_sessions WHERE id = ${sessionId} AND status = 'active'
|
||||
`
|
||||
if (!session) return
|
||||
|
||||
// Notify both parties
|
||||
const data = { type: 'session_expired', session_id: sessionId }
|
||||
sendToSessionParticipant(sessionId, 'customer', data)
|
||||
sendToSessionParticipant(sessionId, 'mitra', data)
|
||||
|
||||
// Also publish via Valkey for any listeners
|
||||
await publish(`session:${sessionId}:status`, data)
|
||||
}
|
||||
|
||||
// Restore timers for active sessions on server restart
|
||||
export const restoreActiveTimers = async () => {
|
||||
const activeSessions = await sql`
|
||||
SELECT id, expires_at FROM chat_sessions
|
||||
WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at > NOW()
|
||||
`
|
||||
for (const session of activeSessions) {
|
||||
startSessionTimer(session.id, session.expires_at)
|
||||
}
|
||||
if (activeSessions.length > 0) {
|
||||
console.log(`Restored ${activeSessions.length} session timer(s)`)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,12 @@ const sql = getDb()
|
||||
export const getActiveSessionByCustomer = async (customerId) => {
|
||||
const [session] = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||
m.display_name AS mitra_display_name
|
||||
FROM chat_sessions cs
|
||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||
WHERE cs.customer_id = ${customerId}
|
||||
AND cs.status IN ('active', 'pending_payment')
|
||||
AND cs.status IN ('active', 'pending_payment', 'extending', 'closing')
|
||||
ORDER BY cs.created_at DESC LIMIT 1
|
||||
`
|
||||
return session
|
||||
@@ -19,11 +20,12 @@ export const getActiveSessionByCustomer = async (customerId) => {
|
||||
export const getActiveSessionsByMitra = async (mitraId) => {
|
||||
const sessions = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
|
||||
cs.duration_minutes, cs.expires_at, cs.extended_minutes,
|
||||
c.display_name AS customer_display_name
|
||||
FROM chat_sessions cs
|
||||
INNER JOIN customers c ON c.id = cs.customer_id
|
||||
WHERE cs.mitra_id = ${mitraId}
|
||||
AND cs.status IN ('active', 'pending_payment')
|
||||
AND cs.status IN ('active', 'pending_payment', 'extending', 'closing')
|
||||
ORDER BY cs.created_at DESC
|
||||
`
|
||||
return sessions
|
||||
@@ -138,6 +140,7 @@ export const listSessions = async ({ page = 1, limit = 20, status } = {}) => {
|
||||
export const getSessionById = async (sessionId) => {
|
||||
const [session] = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||
c.display_name AS customer_display_name,
|
||||
m.display_name AS mitra_display_name
|
||||
FROM chat_sessions cs
|
||||
@@ -147,3 +150,45 @@ export const getSessionById = async (sessionId) => {
|
||||
`
|
||||
return session
|
||||
}
|
||||
|
||||
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
|
||||
const offset = (page - 1) * limit
|
||||
const items = await sql`
|
||||
SELECT cs.id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||
m.display_name AS mitra_display_name,
|
||||
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message,
|
||||
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message
|
||||
FROM chat_sessions cs
|
||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||
WHERE cs.customer_id = ${customerId}
|
||||
AND cs.status = 'completed'
|
||||
ORDER BY cs.ended_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`
|
||||
const [{ count }] = await sql`
|
||||
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = 'completed'
|
||||
`
|
||||
return { items, total: Number(count), page, limit }
|
||||
}
|
||||
|
||||
export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) => {
|
||||
const offset = (page - 1) * limit
|
||||
const items = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||
c.display_name AS customer_display_name,
|
||||
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message,
|
||||
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message
|
||||
FROM chat_sessions cs
|
||||
INNER JOIN customers c ON c.id = cs.customer_id
|
||||
WHERE cs.mitra_id = ${mitraId}
|
||||
AND cs.status = 'completed'
|
||||
ORDER BY cs.ended_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`
|
||||
const [{ count }] = await sql`
|
||||
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = 'completed'
|
||||
`
|
||||
return { items, total: Number(count), page, limit }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user