- 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>
129 lines
4.4 KiB
JavaScript
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
|
|
`
|
|
}
|