feature/IO-3182-Phone-Number-Consent - Checkpoint

This commit is contained in:
Dave Richer
2025-05-20 18:19:39 -04:00
parent 83860152a9
commit 7bd5190bf2
17 changed files with 772 additions and 320 deletions

215
server/sms/consent.js Normal file
View File

@@ -0,0 +1,215 @@
const {
SET_PHONE_NUMBER_CONSENT,
BULK_SET_PHONE_NUMBER_CONSENT,
INSERT_PHONE_NUMBER_CONSENT_HISTORY
} = require("../graphql-client/queries");
const { phone } = require("phone");
const gqlClient = require("../graphql-client/graphql-client").client;
/**
* Set SMS consent for a phone number
* @param req
* @param res
* @returns {Promise<*>}
*/
const setConsent = async (req, res) => {
const { bodyshopid, phone_number, consent_status, reason, changed_by } = req.body;
const {
logger,
ioRedis,
ioHelpers: { getBodyshopRoom },
sessionUtils: { getBodyshopFromRedis }
} = req;
if (!bodyshopid || !phone_number || consent_status === undefined || !reason || !changed_by) {
logger.log("set-consent-error", "ERROR", req.user.email, null, {
type: "missing-parameters",
bodyshopid,
phone_number,
consent_status,
reason,
changed_by
});
return res.status(400).json({ success: false, message: "Missing required parameter(s)." });
}
try {
// Check enforce_sms_consent
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
if (!enforceConsent) {
logger.log("set-consent-error", "ERROR", req.user.email, null, {
type: "consent-not-enforced",
bodyshopid
});
return res.status(403).json({ success: false, message: "SMS consent enforcement is not enabled." });
}
const normalizedPhone = phone(phone_number, "CA").phoneNumber.replace(/^\+1/, "");
const consentResponse = await gqlClient.request(SET_PHONE_NUMBER_CONSENT, {
bodyshopid,
phone_number: normalizedPhone,
consent_status
});
const consent = consentResponse.insert_phone_number_consent_one;
// Log audit history
const historyResponse = await gqlClient.request(INSERT_PHONE_NUMBER_CONSENT_HISTORY, {
objects: [
{
phone_number_consent_id: consent.id,
old_value: null, // Not tracking old value
new_value: consent_status,
reason,
changed_by,
changed_at: "now()"
}
]
});
const history = historyResponse.insert_phone_number_consent_history.returning[0];
// Emit WebSocket event
const broadcastRoom = getBodyshopRoom(bodyshopid);
ioRedis.to(broadcastRoom).emit("consent-changed", {
bodyshopId: bodyshopid,
phone_number: normalizedPhone,
consent_status,
reason
});
logger.log("set-consent-success", "DEBUG", req.user.email, null, {
bodyshopid,
phone_number: normalizedPhone,
consent_status
});
// Return both consent and history
res.status(200).json({
success: true,
consent: {
...consent,
phone_number_consent_history: [history]
}
});
} catch (error) {
logger.log("set-consent-error", "ERROR", req.user.email, null, {
bodyshopid,
phone_number,
error: error.message,
stack: error.stack
});
res.status(500).json({ success: false, message: "Failed to update consent status." });
}
};
/**
* Bulk set SMS consent for multiple phone numbers
* @param req
* @param res
* @returns {Promise<*>}
*/
const bulkSetConsent = async (req, res) => {
const { bodyshopid, consents } = req.body; // consents: [{ phone_number, consent_status }]
const {
logger,
ioRedis,
ioHelpers: { getBodyshopRoom },
sessionUtils: { getBodyshopFromRedis }
} = req;
if (!bodyshopid || !Array.isArray(consents) || consents.length === 0) {
logger.log("bulk-set-consent-error", "ERROR", req.user.email, null, {
type: "missing-parameters",
bodyshopid,
consents
});
return res.status(400).json({ success: false, message: "Missing or invalid parameters." });
}
try {
// Check enforce_sms_consent
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
if (!enforceConsent) {
logger.log("bulk-set-consent-error", "ERROR", req.user.email, null, {
type: "consent-not-enforced",
bodyshopid
});
return res.status(403).json({ success: false, message: "SMS consent enforcement is not enabled." });
}
const objects = consents.map(({ phone_number, consent_status }) => ({
bodyshopid,
phone_number: phone(phone_number, "CA").phoneNumber.replace(/^\+1/, ""),
consent_status,
consent_updated_at: "now()"
}));
// Insert or update phone_number_consent records
const consentResponse = await gqlClient.request(BULK_SET_PHONE_NUMBER_CONSENT, {
objects
});
const updatedConsents = consentResponse.insert_phone_number_consent.returning;
// Log audit history
const historyObjects = updatedConsents.map((consent) => ({
phone_number_consent_id: consent.id,
old_value: null, // Not tracking old value for bulk updates
new_value: consent.consent_status,
reason: "System update via bulk upload",
changed_by: "system",
changed_at: "now()"
}));
const historyResponse = await gqlClient.request(INSERT_PHONE_NUMBER_CONSENT_HISTORY, {
objects: historyObjects
});
const history = historyResponse.insert_phone_number_consent_history.returning;
// Combine consents with their history
const consentsWithhistory = updatedConsents.map((consent, index) => ({
...consent,
phone_number_consent_history: [history[index]]
}));
// Emit WebSocket events for each consent change
const broadcastRoom = getBodyshopRoom(bodyshopid);
updatedConsents.forEach((consent) => {
ioRedis.to(broadcastRoom).emit("consent-changed", {
bodyshopId: bodyshopid,
phone_number: consent.phone_number,
consent_status: consent.consent_status,
reason: "System update via bulk upload"
});
});
logger.log("bulk-set-consent-success", "DEBUG", req.user.email, null, {
bodyshopid,
updatedCount: updatedConsents.length
});
res.status(200).json({
success: true,
updatedCount: updatedConsents.length,
consents: consentsWithhistory
});
} catch (error) {
logger.log("bulk-set-consent-error", "ERROR", req.user.email, null, {
bodyshopid,
error: error.message,
stack: error.stack
});
res.status(500).json({ success: false, message: "Failed to update consents." });
}
};
module.exports = {
setConsent,
bulkSetConsent
};

View File

@@ -8,65 +8,27 @@ const {
} = require("../graphql-client/queries");
const { phone } = require("phone");
const { admin } = require("../firebase/firebase-handler");
const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default;
/**
* Generate an array of media URLs from the request body
* @param body
* @returns {null|*[]}
*/
const generateMediaArray = (body) => {
const { NumMedia } = body;
if (parseInt(NumMedia) > 0) {
const ret = [];
for (let i = 0; i < parseInt(NumMedia); i++) {
ret.push(body[`MediaUrl${i}`]);
}
return ret;
} else {
return null;
}
};
/**
* Handle errors during the message receiving process
* @param req
* @param error
* @param res
* @param context
*/
const handleError = (req, error, res, context) => {
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),
messagingServiceSid: req.body.MessagingServiceSid,
context,
error
});
res.status(500).json({ error: error.message || "Internal Server Error" });
};
/**
* Receive an inbound SMS message
* 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 }
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
sessionUtils: { getBodyshopFromRedis }
} = req;
const loggerData = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body)
image_path: generateMediaArray(req.body, logger)
};
logger.log("sms-inbound", "DEBUG", "api", null, loggerData);
@@ -92,30 +54,36 @@ const receive = async (req, res) => {
const bodyshop = response.bodyshops[0];
// Step 2: Handle consent
const normalizedPhone = phone(req.body.From, "CA").phoneNumber.replace(/^\+1/, "");
const isStop = req.body.Body.toUpperCase().includes("STOP");
const consentStatus = isStop ? false : true;
const reason = isStop ? "Customer texted STOP" : "Inbound message received";
// Step 2: Check enforce_sms_consent
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
const consentResponse = await client.request(SET_PHONE_NUMBER_CONSENT, {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone,
consent_status: consentStatus,
reason,
changed_by: "system"
});
// Step 3: Handle consent only if enforce_sms_consent is true
if (enforceConsent) {
const normalizedPhone = phone(req.body.From, "CA").phoneNumber.replace(/^\+1/, "");
const isStop = req.body.Body.toUpperCase().includes("STOP");
const consentStatus = isStop ? false : true;
const reason = isStop ? "Customer texted STOP" : "Inbound message received";
// Emit WebSocket event for consent change
const broadcastRoom = getBodyshopRoom(bodyshop.id);
ioRedis.to(broadcastRoom).emit("consent-changed", {
bodyshopId: bodyshop.id,
phone_number: normalizedPhone,
consent_status: consentStatus,
reason
});
const consentResponse = await client.request(SET_PHONE_NUMBER_CONSENT, {
bodyshopid: bodyshop.id,
phone_number: normalizedPhone,
consent_status: consentStatus,
reason,
changed_by: "system"
});
// Step 3: Process conversation
// Emit WebSocket event for consent change
const broadcastRoom = getBodyshopRoom(bodyshop.id);
ioRedis.to(broadcastRoom).emit("consent-changed", {
bodyshopId: bodyshop.id,
phone_number: normalizedPhone,
consent_status: consentStatus,
reason
});
}
// Step 4: 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]
@@ -126,7 +94,7 @@ const receive = async (req, res) => {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
image_path: generateMediaArray(req.body, logger),
isoutbound: false,
userid: null
};
@@ -143,7 +111,7 @@ const receive = async (req, res) => {
const newConversationResponse = await client.request(CREATE_CONVERSATION, {
conversation: {
bodyshopid: bodyshop.id,
phone_num: normalizedPhone,
phone_num: phone(req.body.From).phoneNumber,
archived: false
}
});
@@ -153,7 +121,7 @@ const receive = async (req, res) => {
newMessage.conversationid = conversationid;
// Step 4: Insert the message
// Step 5: Insert the message
const insertresp = await client.request(INSERT_MESSAGE, {
msg: newMessage,
conversationid
@@ -166,7 +134,7 @@ const receive = async (req, res) => {
throw new Error("Conversation data is missing from the response.");
}
// Step 5: Notify clients
// Step 6: Notify clients
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: conversation.bodyshop.id,
conversationId: conversation.id
@@ -179,6 +147,7 @@ const receive = async (req, res) => {
msid: message.sid
};
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
ioRedis.to(broadcastRoom).emit("new-message-summary", {
...commonPayload,
existingConversation: !!existingConversation,
@@ -194,7 +163,7 @@ const receive = async (req, res) => {
summary: false
});
// Step 6: Send FCM notification
// Step 7: Send FCM notification
const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: {
@@ -220,10 +189,51 @@ const receive = async (req, res) => {
res.status(200).send("");
} catch (e) {
handleError(req, e, res, "RECEIVE_MESSAGE");
handleError(req, e, res, "RECEIVE_MESSAGE", logger);
}
};
/**
* Generate media array from the request body
* @param body
* @param logger
* @returns {null|*[]}
*/
const generateMediaArray = (body, logger) => {
const { NumMedia } = body;
if (parseInt(NumMedia) > 0) {
const ret = [];
for (let i = 0; i < parseInt(NumMedia); 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
};

View File

@@ -1,21 +1,16 @@
const twilio = require("twilio");
const { phone } = require("phone");
const { INSERT_MESSAGE } = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { INSERT_MESSAGE, GET_PHONE_NUMBER_CONSENT } = require("../graphql-client/queries");
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const gqlClient = require("../graphql-client/graphql-client").client;
/**
* Send an outbound SMS message
* @param req
* @param res
* @returns {Promise<void>}
*/
const send = async (req, res) => {
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body;
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid, bodyshopid } = req.body;
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
logger,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
sessionUtils: { getBodyshopFromRedis }
} = req;
logger.log("sms-outbound", "DEBUG", req.user.email, null, {
@@ -26,11 +21,11 @@ const send = async (req, res) => {
conversationid,
isoutbound: true,
userid: req.user.email,
image: req.body.selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
image: selectedMedia.length > 0,
image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
});
if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) {
if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid || !bodyshopid) {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
type: "missing-parameters",
messagingServiceSid,
@@ -39,14 +34,38 @@ const send = async (req, res) => {
conversationid,
isoutbound: true,
userid: req.user.email,
image: req.body.selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
image: selectedMedia.length > 0,
image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
});
res.status(400).json({ success: false, message: "Missing required parameter(s)." });
return;
}
try {
// Check bodyshop's enforce_sms_consent setting
const bodyShopData = await getBodyshopFromRedis(bodyshopid);
const enforceConsent = bodyShopData?.enforce_sms_consent ?? false;
// Check consent only if enforcement is enabled
if (enforceConsent) {
const normalizedPhone = phone(to, "CA").phoneNumber.replace(/^\+1/, "");
const consentResponse = await gqlClient.request(GET_PHONE_NUMBER_CONSENT, {
bodyshopid,
phone_number: normalizedPhone
});
if (!consentResponse.phone_number_consent?.length || !consentResponse.phone_number_consent[0].consent_status) {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
type: "no-consent",
phone_number: normalizedPhone,
conversationid
});
return res.status(403).json({
success: false,
message: "Phone number has not consented to messaging."
});
}
}
const message = await client.messages.create({
body,
messagingServiceSid,
@@ -60,8 +79,8 @@ const send = async (req, res) => {
conversationid,
isoutbound: true,
userid: req.user.email,
image: req.body.selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
image: selectedMedia.length > 0,
image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
};
try {