feature/IO-3182-Phone-Number-Consent - Front/Back Start/stop logic complete

This commit is contained in:
Dave Richer
2025-05-26 14:44:27 -04:00
parent 67d5dcb062
commit 51748ce28d
14 changed files with 375 additions and 83 deletions

View File

@@ -2980,4 +2980,59 @@ exports.INSERT_INTEGRATION_LOG = `
id
}
}
`;
`;
exports.INSERT_PHONE_NUMBER_OPT_OUT = `
mutation INSERT_PHONE_NUMBER_OPT_OUT($optOutInput: [phone_number_opt_out_insert_input!]!) {
insert_phone_number_opt_out(objects: $optOutInput, on_conflict: { constraint: phone_number_consent_bodyshopid_phone_number_key, update_columns: [updated_at] }) {
affected_rows
returning {
id
bodyshopid
phone_number
created_at
updated_at
}
}
}
`;
// Query to check if a phone number is opted out
exports.CHECK_PHONE_NUMBER_OPT_OUT = `
query CHECK_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
// Query to check if a phone number is opted out
exports.CHECK_PHONE_NUMBER_OPT_OUT = `
query CHECK_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
// Mutation to delete a phone number opt-out record
exports.DELETE_PHONE_NUMBER_OPT_OUT = `
mutation DELETE_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
delete_phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
affected_rows
returning {
id
bodyshopid
phone_number
}
}
}
`;

View File

@@ -3,7 +3,10 @@ const {
FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID,
UNARCHIVE_CONVERSATION,
CREATE_CONVERSATION,
INSERT_MESSAGE
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");
@@ -51,8 +54,81 @@ const receive = async (req, res) => {
}
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 4: Process conversation
// Step 2: Check for opt-in or opt-out keywords
const optInKeywords = ["START", "YES", "UNSTOP"];
const optOutKeywords = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
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
});
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
});
// Emit WebSocket event to notify clients
const broadcastRoom = getBodyshopRoom(bodyshop.id);
ioRedis.to(broadcastRoom).emit("phone-number-opted-in", {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone
});
}
} 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
});
// Emit WebSocket event to notify clients
const broadcastRoom = getBodyshopRoom(bodyshop.id);
ioRedis.to(broadcastRoom).emit("phone-number-opted-out", {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone
});
}
}
// Respond immediately without processing as a regular message
res.status(200).send("");
return;
}
// Step 3: 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]
@@ -90,7 +166,7 @@ const receive = async (req, res) => {
newMessage.conversationid = conversationid;
// Step 5: Insert the message
// Step 4: Insert the message
const insertresp = await client.request(INSERT_MESSAGE, {
msg: newMessage,
conversationid
@@ -103,7 +179,7 @@ const receive = async (req, res) => {
throw new Error("Conversation data is missing from the response.");
}
// Step 6: Notify clients
// Step 5: Notify clients
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: conversation.bodyshop.id,
conversationId: conversation.id
@@ -133,7 +209,7 @@ const receive = async (req, res) => {
summary: false
});
// Step 7: Send FCM notification
// Step 6: Send FCM notification
const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: {

View File

@@ -1,6 +1,12 @@
const client = require("../graphql-client/graphql-client").client;
const { UPDATE_MESSAGE_STATUS, MARK_MESSAGES_AS_READ } = require("../graphql-client/queries");
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");
/**
* Handle the status of an SMS message
@@ -9,7 +15,7 @@ const logger = require("../utils/logger");
* @returns {Promise<*>}
*/
const status = async (req, res) => {
const { SmsSid, SmsStatus } = req.body;
const { SmsSid, SmsStatus, ErrorCode, To, MessagingServiceSid } = req.body;
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
@@ -21,18 +27,76 @@ const status = async (req, res) => {
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];
const message = response.update_messages?.returning?.[0];
if (message) {
logger.log("sms-status-update", "DEBUG", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus }
status: SmsStatus
});
// Emit WebSocket event to notify the change in message status
@@ -47,20 +111,20 @@ const status = async (req, res) => {
type: "status-changed"
});
} else {
logger.log("sms-status-update-warning", "WARN", "api", null, {
logger.log("sms-status-update-warning", "WARN", null, null, {
msid: SmsSid,
fields: { status: SmsStatus },
warning: "No message returned from the database update."
status: SmsStatus,
warning: "No message found in database for update"
});
}
res.sendStatus(200);
} catch (error) {
} catch (err) {
logger.log("sms-status-update-error", "ERROR", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus },
stack: error.stack,
message: error.message
status: SmsStatus,
error: err.message,
stack: err.stack
});
res.status(500).json({ error: "Failed to update message status." });
}