const client = require("../graphql-client/graphql-client").client; const { UPDATE_MESSAGE_STATUS, MARK_MESSAGES_AS_READ, INSERT_PHONE_NUMBER_OPT_OUT, FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID } = require("../graphql-client/queries"); const logger = require("../utils/logger"); const { phone } = require("phone"); // Local GraphQL for “mark unread” (kept here to avoid requiring edits to graphql-client/queries.js) const GET_LAST_INBOUND_NON_SYSTEM_MESSAGE = ` query GetLastInboundNonSystemMessage($conversationId: uuid!) { messages( where: { conversationid: { _eq: $conversationId } isoutbound: { _eq: false } is_system: { _eq: false } } order_by: { created_at: desc } limit: 1 ) { id created_at read } } `; const MARK_LAST_INBOUND_MESSAGE_UNREAD_MAX_ONE = ` mutation MarkLastInboundMessageUnreadMaxOne($conversationId: uuid!, $lastId: uuid!) { markOthersRead: update_messages( _set: { read: true } where: { conversationid: { _eq: $conversationId } isoutbound: { _eq: false } is_system: { _eq: false } id: { _neq: $lastId } } ) { affected_rows returning { id } } markLastUnread: update_messages_by_pk(pk_columns: { id: $lastId }, _set: { read: false }) { id } } `; /** * Handle the status of an SMS message * @param req * @param res * @returns {Promise<*>} */ const status = async (req, res) => { const { SmsSid, SmsStatus, ErrorCode, To, MessagingServiceSid } = req.body; const { ioRedis, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } } = req; try { // Ignore status 'queued' if (SmsStatus === "queued") { return res.status(200).json({ message: "Status 'queued' disregarded." }); } // Handle ErrorCode 21610 (Attempt to send to unsubscribed recipient) first if (ErrorCode === "21610" && To && MessagingServiceSid) { try { // Step 1: Find the bodyshop by MessagingServiceSid const bodyshopResponse = await client.request(FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, { mssid: MessagingServiceSid, phone: phone(To).phoneNumber // Pass the normalized phone number as required }); const bodyshop = bodyshopResponse.bodyshops[0]; if (!bodyshop) { logger.log("sms-opt-out-error", "ERROR", "api", null, { msid: SmsSid, messagingServiceSid: MessagingServiceSid, to: To, error: "No matching bodyshop found" }); } else { // Step 2: Insert into phone_number_opt_out table const now = new Date().toISOString(); const optOutInput = { bodyshopid: bodyshop.id, phone_number: phone(To).phoneNumber.replace(/^\+1/, ""), // Normalize phone number (remove +1 for CA numbers) created_at: now, updated_at: now }; const optOutResponse = await client.request(INSERT_PHONE_NUMBER_OPT_OUT, { optOutInput: [optOutInput] }); logger.log("sms-opt-out-success", "INFO", null, null, { msid: SmsSid, bodyshopid: bodyshop.id, phone_number: optOutInput.phone_number, affected_rows: optOutResponse.insert_phone_number_opt_out.affected_rows }); // Store bodyshopid for potential use in WebSocket notification const broadcastRoom = getBodyshopRoom(bodyshop.id); ioRedis.to(broadcastRoom).emit("phone-number-opted-out", { bodyshopid: bodyshop.id, phone_number: optOutInput.phone_number // Note: conversationId is not included yet; will be set after message lookup }); } } catch (error) { logger.log("sms-opt-out-error", "ERROR", "api", null, { msid: SmsSid, messagingServiceSid: MessagingServiceSid, to: To, error: error.message, stack: error.stack }); // Continue processing to update message status } } // Update message status in the database const response = await client.request(UPDATE_MESSAGE_STATUS, { msid: SmsSid, fields: { status: SmsStatus } }); const message = response.update_messages?.returning?.[0]; if (message) { logger.log("sms-status-update", "DEBUG", "api", null, { msid: SmsSid, status: SmsStatus }); // Emit WebSocket event to notify the change in message status const conversationRoom = getBodyshopConversationRoom({ bodyshopId: message.conversation.bodyshopid, conversationId: message.conversationid }); ioRedis.to(conversationRoom).emit("message-changed", { ...message, status: SmsStatus, type: "status-changed" }); } else { logger.log("sms-status-update-warning", "WARN", null, null, { msid: SmsSid, status: SmsStatus, warning: "No message found in database for update" }); } res.sendStatus(200); } catch (err) { logger.log("sms-status-update-error", "ERROR", "api", null, { msid: SmsSid, status: SmsStatus, error: err.message, stack: err.stack }); res.status(500).json({ error: "Failed to update message status." }); } }; /** * Mark a conversation as read * @param req * @param res * @returns {Promise<*>} */ const markConversationRead = async (req, res) => { const { ioRedis, ioHelpers: { getBodyshopRoom } } = req; const { conversation, imexshopid, bodyshopid } = req.body; // Alternatively, support both payload formats const conversationId = conversation?.id || req.body.conversationId; if (!conversationId || !imexshopid || !bodyshopid) { return res.status(400).json({ error: "Invalid conversation data provided." }); } try { const response = await client.request(MARK_MESSAGES_AS_READ, { conversationId }); const updatedMessageIds = response.update_messages.returning.map((message) => message.id); const broadcastRoom = getBodyshopRoom(bodyshopid); ioRedis.to(broadcastRoom).emit("conversation-changed", { type: "conversation-marked-read", conversationId, affectedMessages: response.update_messages.affected_rows, messageIds: updatedMessageIds }); res.status(200).json({ success: true, message: "Conversation marked as read." }); } catch (error) { console.error("Error marking conversation as read:", error); res.status(500).json({ error: "Failed to mark conversation as read." }); } }; /** * Mark last inbound (customer) non-system message as unread. * Enforces: max unread inbound non-system messages per thread = 1 * * Body: { conversationId, imexshopid, bodyshopid } */ const markLastMessageUnread = async (req, res) => { const { ioRedis, ioHelpers: { getBodyshopRoom } } = req; const { conversationId, imexshopid, bodyshopid } = req.body; if (!conversationId || !imexshopid || !bodyshopid) { return res.status(400).json({ error: "Invalid conversation data provided." }); } try { const lastResp = await client.request(GET_LAST_INBOUND_NON_SYSTEM_MESSAGE, { conversationId }); const last = lastResp?.messages?.[0]; if (!last?.id) { // No inbound message to mark unread const broadcastRoom = getBodyshopRoom(bodyshopid); ioRedis.to(broadcastRoom).emit("conversation-changed", { type: "conversation-marked-unread", conversationId, lastUnreadMessageId: null, messageIdsMarkedRead: [], unreadCount: 0 }); return res.status(200).json({ success: true, conversationId, lastUnreadMessageId: null, messageIdsMarkedRead: [], unreadCount: 0 }); } const mutResp = await client.request(MARK_LAST_INBOUND_MESSAGE_UNREAD_MAX_ONE, { conversationId, lastId: last.id }); const messageIdsMarkedRead = mutResp?.markOthersRead?.returning?.map((m) => m.id) || []; const broadcastRoom = getBodyshopRoom(bodyshopid); ioRedis.to(broadcastRoom).emit("conversation-changed", { type: "conversation-marked-unread", conversationId, lastUnreadMessageId: last.id, messageIdsMarkedRead, unreadCount: 1 }); return res.status(200).json({ success: true, conversationId, lastUnreadMessageId: last.id, messageIdsMarkedRead, unreadCount: 1 }); } catch (error) { console.error("Error marking last message unread:", error); return res.status(500).json({ error: "Failed to mark last message unread." }); } }; module.exports = { status, markConversationRead, markLastMessageUnread };