Files
halobestie-clone/backend/src/services/chat.service.js
ramadhan sjamsani b0502ac92b Phase 3 testing fixes: Fastify 5, SSE→WebSocket+FCM, enums, security, session lifecycle
- Upgrade Fastify 4→5 with all plugins (@fastify/websocket 11, cors 11, sensible 6)
- Migrate all SSE endpoints to WebSocket + FCM push (mitra chat requests, customer pairing status)
- Add flutter_local_notifications for foreground push notifications with sound
- Add splash screen to both apps (hide auth loading flash)
- Introduce constants/enums across entire codebase (no raw string literals)
- Move price tiers from hardcoded array to app_config DB (data-driven, includes 1-min test tier)
- Add session ownership validation on all shared chat routes
- Add ownership checks on endSession, respondToExtension, requestExtension
- Fix session timer: auto-complete expired/stale sessions on server restart
- Add 5-min grace period for abandoned closing sessions
- Fix extension flow: proper session_resumed handling, clearExtensionRequest, closure grace timer cleanup
- Fix chat screens: ConnectChat in initState, session status check on connect
- Fix customer expired view: 5-min countdown, closure state priority over expired state
- Fix mitra extension UI: loading spinner, disable buttons, handle EXTENSION_RESOLVED error
- Fix GoRouter navigation consistency (no more Navigator.pushNamed)
- Fix goodbye view keyboard overflow (SingleChildScrollView)
- Add active session card on customer home screen with refresh on navigate back
- Fix PricingBottomSheet extension mode (RequestExtension instead of new pairing)
- Send session_resumed to both parties on extension accept

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:17:25 +08:00

129 lines
4.4 KiB
JavaScript

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'
import { UserType, SessionStatus, MessageStatus, MessageType, WsMessage } from '../constants.js'
const sql = getDb()
export const sendMessage = async ({ sessionId, senderType, senderId, content, type = MessageType.TEXT }) => {
// Verify session is active
const [session] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
WHERE id = ${sessionId} AND status = ${SessionStatus.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}, ${MessageStatus.SENT})
RETURNING id, session_id, sender_type, sender_id, type, content, status, created_at
`
// Send ack to sender
sendToSessionParticipant(sessionId, senderType, {
type: WsMessage.MESSAGE_ACK,
message_id: message.id,
status: MessageStatus.SENT,
created_at: message.created_at,
})
// Determine recipient
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
const recipientId = senderType === UserType.CUSTOMER ? session.mitra_id : session.customer_id
// Try to send via WebSocket
const delivered = sendToSessionParticipant(sessionId, recipientType, {
type: WsMessage.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 === UserType.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 = ${MessageStatus.DELIVERED}, delivered_at = NOW()
WHERE id = ANY(${messageIds})
AND session_id = ${sessionId}
AND status = ${MessageStatus.SENT}
`
// Notify sender about delivery
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
sendToSessionParticipant(sessionId, recipientType, {
type: WsMessage.MESSAGE_STATUS,
message_ids: messageIds,
status: MessageStatus.DELIVERED,
})
}
export const markRead = async (sessionId, senderType, messageIds) => {
if (!messageIds || messageIds.length === 0) return
await sql`
UPDATE chat_messages
SET status = ${MessageStatus.READ}, read_at = NOW()
WHERE id = ANY(${messageIds})
AND session_id = ${sessionId}
AND status IN (${MessageStatus.SENT}, ${MessageStatus.DELIVERED})
`
// Notify sender about read
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
sendToSessionParticipant(sessionId, recipientType, {
type: WsMessage.MESSAGE_STATUS,
message_ids: messageIds,
status: MessageStatus.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 === UserType.CUSTOMER ? UserType.MITRA : UserType.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 = ${MessageStatus.SENT}
ORDER BY created_at ASC
`
}