const client = require("../graphql-client/graphql-client").client; const { FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, UNARCHIVE_CONVERSATION, CREATE_CONVERSATION, INSERT_MESSAGE, CHECK_PHONE_NUMBER_OPT_OUT, DELETE_PHONE_NUMBER_OPT_OUT, INSERT_PHONE_NUMBER_OPT_OUT } = require("../graphql-client/queries"); const { phone } = require("phone"); const { admin } = require("../firebase/firebase-handler"); const InstanceManager = require("../utils/instanceMgr").default; // Note: When we handle different languages, we might need to adjust these keywords accordingly. const optInKeywords = ["START", "YES", "UNSTOP"]; const optOutKeywords = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT", "REVOKE", "OPTOUT"]; // System Message text, will also need to be localized if we support multiple languages const systemMessageOptions = { optIn: "Customer has opted-in", optOut: "Customer has opted-out" }; /** * Receive SMS messages from Twilio and process them * @param req * @param res * @returns {Promise<*>} */ const receive = async (req, res) => { const { logger, ioRedis, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } } = req; const loggerData = { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, image_path: generateMediaArray(req.body, logger) }; logger.log("sms-inbound", "DEBUG", "api", null, loggerData); if (!req.body || !req.body.MessagingServiceSid || !req.body.SmsMessageSid) { logger.log("sms-inbound-error", "ERROR", "api", null, { ...loggerData, type: "malformed-request" }); return res.status(400).json({ success: false, error: "Malformed Request" }); } try { // Step 1: Find the bodyshop and existing conversation const response = await client.request(FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, { mssid: req.body.MessagingServiceSid, phone: phone(req.body.From).phoneNumber }); if (!response.bodyshops[0]) { return res.status(400).json({ success: false, error: "No matching bodyshop" }); } const bodyshop = response.bodyshops[0]; const normalizedPhone = phone(req.body.From).phoneNumber.replace(/^\+1/, ""); // Normalize phone number (remove +1 for CA numbers) const messageText = (req.body.Body || "").trim().toUpperCase(); // Step 2: Process conversation const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const existingConversation = sortedConversations.length ? sortedConversations[sortedConversations.length - 1] : null; let conversationid; if (existingConversation) { conversationid = existingConversation.id; if (existingConversation.archived) { await client.request(UNARCHIVE_CONVERSATION, { id: conversationid, archived: false }); } } else { const newConversationResponse = await client.request(CREATE_CONVERSATION, { conversation: { bodyshopid: bodyshop.id, phone_num: phone(req.body.From).phoneNumber, archived: false } }); const createdConversation = newConversationResponse.insert_conversations.returning[0]; conversationid = createdConversation.id; } // Step 3: Handle opt-in or opt-out keywords let systemMessageText = ""; let socketEventType = ""; if (optInKeywords.includes(messageText) || optOutKeywords.includes(messageText)) { // Check if the phone number is in phone_number_opt_out const optOutCheck = await client.request(CHECK_PHONE_NUMBER_OPT_OUT, { bodyshopid: bodyshop.id, phone_number: normalizedPhone }); // Opt In if (optInKeywords.includes(messageText)) { // Handle opt-in if (optOutCheck.phone_number_opt_out.length > 0) { // Phone number is opted out; delete the record const deleteResponse = await client.request(DELETE_PHONE_NUMBER_OPT_OUT, { bodyshopid: bodyshop.id, phone_number: normalizedPhone }); logger.log("sms-opt-in-success", "INFO", "api", null, { msid: req.body.SmsMessageSid, bodyshopid: bodyshop.id, phone_number: normalizedPhone, affected_rows: deleteResponse.delete_phone_number_opt_out.affected_rows }); systemMessageText = systemMessageOptions.optIn; socketEventType = "phone-number-opted-in"; } } // Opt Out else if (optOutKeywords.includes(messageText)) { // Handle opt-out if (optOutCheck.phone_number_opt_out.length === 0) { // Phone number is not opted out; insert a new record const now = new Date().toISOString(); const optOutInput = { bodyshopid: bodyshop.id, phone_number: normalizedPhone, created_at: now, updated_at: now }; const insertResponse = await client.request(INSERT_PHONE_NUMBER_OPT_OUT, { optOutInput: [optOutInput] }); logger.log("sms-opt-out-success", "INFO", "api", null, { msid: req.body.SmsMessageSid, bodyshopid: bodyshop.id, phone_number: normalizedPhone, affected_rows: insertResponse.insert_phone_number_opt_out.affected_rows }); systemMessageText = systemMessageOptions.optOut; socketEventType = "phone-number-opted-out"; } } // Insert system message if an opt-in or opt-out action was taken if (systemMessageText) { const systemMessage = { msid: `SYS_${req.body.SmsMessageSid}_${Date.now()}`, // Unique ID for system message text: systemMessageText, conversationid, isoutbound: false, userid: null, image: false, image_path: null, is_system: true }; const systemMessageResponse = await client.request(INSERT_MESSAGE, { msg: systemMessage, conversationid }); const insertedSystemMessage = systemMessageResponse.insert_messages.returning[0]; // Emit WebSocket events for system message const broadcastRoom = getBodyshopRoom(bodyshop.id); const conversationRoom = getBodyshopConversationRoom({ bodyshopId: bodyshop.id, conversationId: conversationid }); const systemPayload = { isoutbound: false, conversationId: conversationid, updated_at: insertedSystemMessage.updated_at, msid: insertedSystemMessage.msid, existingConversation: !!existingConversation, newConversation: !existingConversation ? insertedSystemMessage.conversation : null }; ioRedis.to(broadcastRoom).emit("new-message-summary", { ...systemPayload, summary: true }); ioRedis.to(conversationRoom).emit("new-message-detailed", { newMessage: insertedSystemMessage, ...systemPayload, summary: false }); // Emit opt-in or opt-out event ioRedis.to(broadcastRoom).emit(socketEventType, { bodyshopid: bodyshop.id, phone_number: normalizedPhone }); } } // Step 4: Insert the original message const newMessage = { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, image_path: generateMediaArray(req.body, logger), isoutbound: false, userid: null, conversationid, is_system: false }; const insertresp = await client.request(INSERT_MESSAGE, { msg: newMessage, conversationid }); const message = insertresp?.insert_messages?.returning?.[0]; const conversation = message?.conversation || null; if (!conversation) { throw new Error("Conversation data is missing from the response."); } // Step 5: Notify clients for original message const conversationRoom = getBodyshopConversationRoom({ bodyshopId: conversation.bodyshop.id, conversationId: conversation.id }); const commonPayload = { isoutbound: false, conversationId: conversation.id, updated_at: message.updated_at, msid: message.msid }; const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id); ioRedis.to(broadcastRoom).emit("new-message-summary", { ...commonPayload, existingConversation: !!existingConversation, newConversation: !existingConversation ? conversation : null, summary: true }); ioRedis.to(conversationRoom).emit("new-message-detailed", { newMessage: message, ...commonPayload, newConversation: !existingConversation ? conversation : null, existingConversation: !!existingConversation, summary: false }); // Step 6: Send FCM notification const fcmresp = await admin.messaging().send({ topic: `${message.conversation.bodyshop.imexshopid}-messaging`, notification: { title: InstanceManager({ imex: `ImEX Online Message - ${message.conversation.phone_num}`, rome: `Rome Online Message - ${message.conversation.phone_num}` }), body: message.image_path ? `Image ${message.text}` : message.text }, data: { type: "messaging-inbound", conversationid: message.conversationid || "", text: message.text || "", messageid: message.id || "", phone_num: message.conversation.phone_num || "" } }); logger.log("sms-inbound-success", "DEBUG", "api", null, { newMessage, fcmresp }); res.status(200).send(""); } catch (e) { handleError({ req, res, logger, error: e, context: "RECEIVE_MESSAGE" }); } }; /** * Generate media array from the request body * @param body * @param logger * @returns {null|*[]} */ const generateMediaArray = (body) => { const { NumMedia } = body; if (parseInt(NumMedia, 10) > 0) { const ret = []; for (let i = 0; i < parseInt(NumMedia, 10); i++) { ret.push(body[`MediaUrl${i}`]); } return ret; } else { return null; } }; /** * Handle error logging and response * @param req * @param error * @param res * @param context * @param logger */ const handleError = ({ req, error, res, context, logger }) => { logger.log("sms-inbound-error", "ERROR", "api", null, { msid: req.body.SmsMessageSid, text: req.body.Body, image: !!req.body.MediaUrl0, image_path: generateMediaArray(req.body, logger), messagingServiceSid: req.body.MessagingServiceSid, context, error }); res.status(500).json({ error: error.message || "Internal Server Error" }); }; module.exports = { receive };