feature/IO-3182-Phone-Number-Consent - Front/Back Start/stop logic complete
This commit is contained in:
@@ -3,6 +3,7 @@ import { Button, Form, InputNumber, Popover, Space } from "antd";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
|
|
||||||
export default function CABCpvrtCalculator({ disabled, form }) {
|
export default function CABCpvrtCalculator({ disabled, form }) {
|
||||||
const [visibility, setVisibility] = useState(false);
|
const [visibility, setVisibility] = useState(false);
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export default function CABCpvrtCalculator({ disabled, form }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
|
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
|
||||||
<Button disabled={disabled} onClick={() => setVisibility(true)}>
|
<Button disabled={disabled} onClick={() => setVisibility(true)}>
|
||||||
<CalculatorFilled />
|
<CalculatorFilled />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -202,8 +202,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
|||||||
text: message.text
|
text: message.text
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add cases for other known message types as needed
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Log a warning for unhandled message types
|
// Log a warning for unhandled message types
|
||||||
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
|
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
|
||||||
@@ -211,7 +209,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return messageRef; // Keep other messages unchanged
|
return messageRef;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,11 +243,8 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updatedList = existingList?.conversations
|
const updatedList = existingList?.conversations
|
||||||
? [
|
? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
|
||||||
newConversation,
|
: [newConversation]; // Prevent duplicates
|
||||||
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
|
|
||||||
]
|
|
||||||
: [newConversation];
|
|
||||||
|
|
||||||
client.cache.writeQuery({
|
client.cache.writeQuery({
|
||||||
query: CONVERSATION_LIST_QUERY,
|
query: CONVERSATION_LIST_QUERY,
|
||||||
@@ -403,6 +398,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logLocal("handleConversationChanged - Unhandled type", { type });
|
logLocal("handleConversationChanged - Unhandled type", { type });
|
||||||
client.cache.modify({
|
client.cache.modify({
|
||||||
@@ -419,10 +415,95 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Existing handler for phone number opt-out
|
||||||
|
const handlePhoneNumberOptedOut = async (data) => {
|
||||||
|
const { bodyshopid, phone_number } = data;
|
||||||
|
logLocal("handlePhoneNumberOptedOut - Start", data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.cache.modify({
|
||||||
|
id: "ROOT_QUERY",
|
||||||
|
fields: {
|
||||||
|
phone_number_opt_out(existing = [], { readField }) {
|
||||||
|
const phoneNumberExists = existing.some(
|
||||||
|
(ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid
|
||||||
|
);
|
||||||
|
|
||||||
|
if (phoneNumberExists) {
|
||||||
|
logLocal("handlePhoneNumberOptedOut - Phone number already in cache", { phone_number, bodyshopid });
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOptOut = {
|
||||||
|
__typename: "phone_number_opt_out",
|
||||||
|
id: `temporary-${phone_number}-${Date.now()}`,
|
||||||
|
bodyshopid,
|
||||||
|
phone_number,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...existing, newOptOut];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: true
|
||||||
|
});
|
||||||
|
|
||||||
|
client.cache.evict({
|
||||||
|
id: "ROOT_QUERY",
|
||||||
|
fieldName: "phone_number_opt_out",
|
||||||
|
args: { bodyshopid, search: phone_number }
|
||||||
|
});
|
||||||
|
client.cache.gc();
|
||||||
|
|
||||||
|
logLocal("handlePhoneNumberOptedOut - Cache updated successfully", data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating cache for phone number opt-out:", error);
|
||||||
|
logLocal("handlePhoneNumberOptedOut - Error", { error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// New handler for phone number opt-in
|
||||||
|
const handlePhoneNumberOptedIn = async (data) => {
|
||||||
|
const { bodyshopid, phone_number } = data;
|
||||||
|
logLocal("handlePhoneNumberOptedIn - Start", data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update the Apollo cache for GET_PHONE_NUMBER_OPT_OUTS by removing the phone number
|
||||||
|
client.cache.modify({
|
||||||
|
id: "ROOT_QUERY",
|
||||||
|
fields: {
|
||||||
|
phone_number_opt_out(existing = [], { readField }) {
|
||||||
|
// Filter out the phone number from the opt-out list
|
||||||
|
return existing.filter(
|
||||||
|
(ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: true // Trigger UI updates
|
||||||
|
});
|
||||||
|
|
||||||
|
// Evict the cache entry to force a refetch on next query
|
||||||
|
client.cache.evict({
|
||||||
|
id: "ROOT_QUERY",
|
||||||
|
fieldName: "phone_number_opt_out",
|
||||||
|
args: { bodyshopid, search: phone_number }
|
||||||
|
});
|
||||||
|
client.cache.gc();
|
||||||
|
|
||||||
|
logLocal("handlePhoneNumberOptedIn - Cache updated successfully", data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating cache for phone number opt-in:", error);
|
||||||
|
logLocal("handlePhoneNumberOptedIn - Error", { error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
socket.on("new-message-summary", handleNewMessageSummary);
|
socket.on("new-message-summary", handleNewMessageSummary);
|
||||||
socket.on("new-message-detailed", handleNewMessageDetailed);
|
socket.on("new-message-detailed", handleNewMessageDetailed);
|
||||||
socket.on("message-changed", handleMessageChanged);
|
socket.on("message-changed", handleMessageChanged);
|
||||||
socket.on("conversation-changed", handleConversationChanged);
|
socket.on("conversation-changed", handleConversationChanged);
|
||||||
|
socket.on("phone-number-opted-out", handlePhoneNumberOptedOut);
|
||||||
|
socket.on("phone-number-opted-in", handlePhoneNumberOptedIn);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unregisterMessagingHandlers = ({ socket }) => {
|
export const unregisterMessagingHandlers = ({ socket }) => {
|
||||||
@@ -431,4 +512,6 @@ export const unregisterMessagingHandlers = ({ socket }) => {
|
|||||||
socket.off("new-message-detailed");
|
socket.off("new-message-detailed");
|
||||||
socket.off("message-changed");
|
socket.off("message-changed");
|
||||||
socket.off("conversation-changed");
|
socket.off("conversation-changed");
|
||||||
|
socket.off("phone-number-opted-out");
|
||||||
|
socket.off("phone-number-opted-in");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Icon from "@ant-design/icons";
|
|||||||
import { Tooltip } from "antd";
|
import { Tooltip } from "antd";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import dayjs from "../../utils/day";
|
import dayjs from "../../utils/day";
|
||||||
import { MdDone, MdDoneAll } from "react-icons/md";
|
import { MdClose, MdDone, MdDoneAll } from "react-icons/md";
|
||||||
import { DateTimeFormatter } from "../../utils/DateFormatter";
|
import { DateTimeFormatter } from "../../utils/DateFormatter";
|
||||||
|
|
||||||
export const renderMessage = (messages, index) => {
|
export const renderMessage = (messages, index) => {
|
||||||
@@ -31,13 +31,16 @@ export const renderMessage = (messages, index) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Message status icons */}
|
{/* Message status icons */}
|
||||||
{message.status && (message.status === "sent" || message.status === "delivered") && (
|
{message.status &&
|
||||||
<div className="message-status">
|
(message.status === "sent" || message.status === "delivered" || message.status === "failed") && (
|
||||||
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" />
|
<div className="message-status">
|
||||||
</div>
|
<Icon
|
||||||
)}
|
component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : MdClose}
|
||||||
|
className="message-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outbound message metadata */}
|
{/* Outbound message metadata */}
|
||||||
{message.isoutbound && (
|
{message.isoutbound && (
|
||||||
<div style={{ fontSize: 10 }}>
|
<div style={{ fontSize: 10 }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
|
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
|
||||||
import { Alert, Input, Spin } from "antd";
|
import { Alert, Input, Space, Spin } from "antd";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -68,48 +68,58 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="imex-flex-row" style={{ width: "100%" }}>
|
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||||
{isOptedOut && <Alert message={t("messaging.errors.no_consent")} type="warning" style={{ marginBottom: 8 }} />}
|
{isOptedOut && <Alert message={t("messaging.errors.no_consent")} type="error" />}
|
||||||
<ChatPresetsComponent className="imex-flex-row__margin" />
|
<div className="imex-flex-row" style={{ width: "100%" }}>
|
||||||
<ChatMediaSelector
|
{!isOptedOut && (
|
||||||
conversation={conversation}
|
<>
|
||||||
selectedMedia={selectedMedia}
|
<ChatPresetsComponent disabled={isSending} className="imex-flex-row__margin" />
|
||||||
setSelectedMedia={setSelectedMedia}
|
<ChatMediaSelector
|
||||||
/>
|
disabled={isSending}
|
||||||
<span style={{ flex: 1 }}>
|
conversation={conversation}
|
||||||
<Input.TextArea
|
selectedMedia={selectedMedia}
|
||||||
className="imex-flex-row__margin imex-flex-row__grow"
|
setSelectedMedia={setSelectedMedia}
|
||||||
allowClear
|
/>
|
||||||
autoFocus
|
</>
|
||||||
ref={inputArea}
|
)}
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
<span style={{ flex: 1 }}>
|
||||||
value={message}
|
<Input.TextArea
|
||||||
disabled={isSending || isOptedOut}
|
className="imex-flex-row__margin imex-flex-row__grow"
|
||||||
placeholder={t("messaging.labels.typeamessage")}
|
allowClear
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
autoFocus
|
||||||
onPressEnter={(event) => {
|
ref={inputArea}
|
||||||
event.preventDefault();
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
if (!event.shiftKey && !isOptedOut) handleEnter();
|
value={message}
|
||||||
}}
|
disabled={isSending || isOptedOut}
|
||||||
/>
|
placeholder={t("messaging.labels.typeamessage")}
|
||||||
</span>
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
<SendOutlined
|
onPressEnter={(event) => {
|
||||||
className="chat-send-message-button"
|
event.preventDefault();
|
||||||
disabled={isOptedOut || message === "" || !message}
|
if (!event.shiftKey && !isOptedOut) handleEnter();
|
||||||
onClick={handleEnter}
|
|
||||||
/>
|
|
||||||
<Spin
|
|
||||||
style={{ display: `${isSending ? "" : "none"}` }}
|
|
||||||
indicator={
|
|
||||||
<LoadingOutlined
|
|
||||||
style={{
|
|
||||||
fontSize: 24
|
|
||||||
}}
|
}}
|
||||||
spin
|
|
||||||
/>
|
/>
|
||||||
}
|
</span>
|
||||||
/>
|
{!isOptedOut && (
|
||||||
</div>
|
<SendOutlined
|
||||||
|
className="chat-send-message-button"
|
||||||
|
disabled={isSending || message === "" || !message}
|
||||||
|
onClick={handleEnter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Spin
|
||||||
|
style={{ display: `${isSending ? "" : "none"}` }}
|
||||||
|
indicator={
|
||||||
|
<LoadingOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: 24
|
||||||
|
}}
|
||||||
|
spin
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -650,7 +650,7 @@ function Header({
|
|||||||
icon: <OneToOneOutlined />,
|
icon: <OneToOneOutlined />,
|
||||||
label: t("menus.header.remoteassist"),
|
label: t("menus.header.remoteassist"),
|
||||||
children: [
|
children: [
|
||||||
...(InstanceRenderManager({ imex: true, rome: true })
|
...(InstanceRenderManager({ imex: true, rome: false })
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: "rescue",
|
key: "rescue",
|
||||||
@@ -662,7 +662,7 @@ function Header({
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
key: "rescue",
|
key: "rescue-zoho",
|
||||||
id: "header-rescue-zoho",
|
id: "header-rescue-zoho",
|
||||||
icon: <UsergroupAddOutlined />,
|
icon: <UsergroupAddOutlined />,
|
||||||
label: t("menus.header.rescuemezoho"),
|
label: t("menus.header.rescuemezoho"),
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function JobEmployeeAssignments({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover destroyTooltipOnHide content={popContent} open={visibility}>
|
<Popover destroyOnHidden content={popContent} open={visibility}>
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
<DataLabel label={t("jobs.fields.employee_body")}>
|
<DataLabel label={t("jobs.fields.employee_body")}>
|
||||||
{body ? (
|
{body ? (
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
|
|||||||
open={open}
|
open={open}
|
||||||
placement="left"
|
placement="left"
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
destroyTooltipOnHide
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<SearchOutlined style={{ cursor: "pointer" }} />
|
<SearchOutlined style={{ cursor: "pointer" }} />
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function PartsOrderBackorderEta({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
|
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
|
||||||
<DateFormatter>{backordered_eta}</DateFormatter>
|
<DateFormatter>{backordered_eta}</DateFormatter>
|
||||||
{isAlreadyBackordered && <CalendarFilled style={{ cursor: "pointer" }} onClick={handlePopover} />}
|
{isAlreadyBackordered && <CalendarFilled style={{ cursor: "pointer" }} onClick={handlePopover} />}
|
||||||
{loading && <Spin />}
|
{loading && <Spin />}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function PartsOrderLineBackorderButton({ partsOrderStatus, partsLineId, j
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
|
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
|
||||||
<Button loading={loading} onClick={handlePopover}>
|
<Button loading={loading} onClick={handlePopover}>
|
||||||
{isAlreadyBackordered ? t("parts_orders.actions.receive") : t("parts_orders.actions.backordered")}
|
{isAlreadyBackordered ? t("parts_orders.actions.receive") : t("parts_orders.actions.backordered")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
|||||||
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
|
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover destroyTooltipOnHide content={popContent} open={visibility}>
|
<Popover destroyOnHidden content={popContent} open={visibility}>
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{record[type] ? (
|
{record[type] ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function TimeTicketCalculatorComponent({
|
|||||||
open={visible}
|
open={visible}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
placement="right"
|
placement="right"
|
||||||
destroyTooltipOnHide
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Button onClick={(e) => e.preventDefault()}>
|
<Button onClick={(e) => e.preventDefault()}>
|
||||||
<Space>
|
<Space>
|
||||||
|
|||||||
@@ -2981,3 +2981,58 @@ exports.INSERT_INTEGRATION_LOG = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ const {
|
|||||||
FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID,
|
FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID,
|
||||||
UNARCHIVE_CONVERSATION,
|
UNARCHIVE_CONVERSATION,
|
||||||
CREATE_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");
|
} = require("../graphql-client/queries");
|
||||||
const { phone } = require("phone");
|
const { phone } = require("phone");
|
||||||
const { admin } = require("../firebase/firebase-handler");
|
const { admin } = require("../firebase/firebase-handler");
|
||||||
@@ -51,8 +54,81 @@ const receive = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bodyshop = response.bodyshops[0];
|
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 sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||||
const existingConversation = sortedConversations.length
|
const existingConversation = sortedConversations.length
|
||||||
? sortedConversations[sortedConversations.length - 1]
|
? sortedConversations[sortedConversations.length - 1]
|
||||||
@@ -90,7 +166,7 @@ const receive = async (req, res) => {
|
|||||||
|
|
||||||
newMessage.conversationid = conversationid;
|
newMessage.conversationid = conversationid;
|
||||||
|
|
||||||
// Step 5: Insert the message
|
// Step 4: Insert the message
|
||||||
const insertresp = await client.request(INSERT_MESSAGE, {
|
const insertresp = await client.request(INSERT_MESSAGE, {
|
||||||
msg: newMessage,
|
msg: newMessage,
|
||||||
conversationid
|
conversationid
|
||||||
@@ -103,7 +179,7 @@ const receive = async (req, res) => {
|
|||||||
throw new Error("Conversation data is missing from the response.");
|
throw new Error("Conversation data is missing from the response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Notify clients
|
// Step 5: Notify clients
|
||||||
const conversationRoom = getBodyshopConversationRoom({
|
const conversationRoom = getBodyshopConversationRoom({
|
||||||
bodyshopId: conversation.bodyshop.id,
|
bodyshopId: conversation.bodyshop.id,
|
||||||
conversationId: conversation.id
|
conversationId: conversation.id
|
||||||
@@ -133,7 +209,7 @@ const receive = async (req, res) => {
|
|||||||
summary: false
|
summary: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 7: Send FCM notification
|
// Step 6: Send FCM notification
|
||||||
const fcmresp = await admin.messaging().send({
|
const fcmresp = await admin.messaging().send({
|
||||||
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
|
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
|
||||||
notification: {
|
notification: {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
const client = require("../graphql-client/graphql-client").client;
|
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 logger = require("../utils/logger");
|
||||||
|
const { phone } = require("phone");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the status of an SMS message
|
* Handle the status of an SMS message
|
||||||
@@ -9,7 +15,7 @@ const logger = require("../utils/logger");
|
|||||||
* @returns {Promise<*>}
|
* @returns {Promise<*>}
|
||||||
*/
|
*/
|
||||||
const status = async (req, res) => {
|
const status = async (req, res) => {
|
||||||
const { SmsSid, SmsStatus } = req.body;
|
const { SmsSid, SmsStatus, ErrorCode, To, MessagingServiceSid } = req.body;
|
||||||
const {
|
const {
|
||||||
ioRedis,
|
ioRedis,
|
||||||
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
|
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
|
||||||
@@ -21,18 +27,76 @@ const status = async (req, res) => {
|
|||||||
return res.status(200).json({ message: "Status 'queued' disregarded." });
|
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
|
// Update message status in the database
|
||||||
const response = await client.request(UPDATE_MESSAGE_STATUS, {
|
const response = await client.request(UPDATE_MESSAGE_STATUS, {
|
||||||
msid: SmsSid,
|
msid: SmsSid,
|
||||||
fields: { status: SmsStatus }
|
fields: { status: SmsStatus }
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = response.update_messages.returning[0];
|
const message = response.update_messages?.returning?.[0];
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
logger.log("sms-status-update", "DEBUG", "api", null, {
|
logger.log("sms-status-update", "DEBUG", "api", null, {
|
||||||
msid: SmsSid,
|
msid: SmsSid,
|
||||||
fields: { status: SmsStatus }
|
status: SmsStatus
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit WebSocket event to notify the change in message status
|
// Emit WebSocket event to notify the change in message status
|
||||||
@@ -47,20 +111,20 @@ const status = async (req, res) => {
|
|||||||
type: "status-changed"
|
type: "status-changed"
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.log("sms-status-update-warning", "WARN", "api", null, {
|
logger.log("sms-status-update-warning", "WARN", null, null, {
|
||||||
msid: SmsSid,
|
msid: SmsSid,
|
||||||
fields: { status: SmsStatus },
|
status: SmsStatus,
|
||||||
warning: "No message returned from the database update."
|
warning: "No message found in database for update"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
logger.log("sms-status-update-error", "ERROR", "api", null, {
|
logger.log("sms-status-update-error", "ERROR", "api", null, {
|
||||||
msid: SmsSid,
|
msid: SmsSid,
|
||||||
fields: { status: SmsStatus },
|
status: SmsStatus,
|
||||||
stack: error.stack,
|
error: err.message,
|
||||||
message: error.message
|
stack: err.stack
|
||||||
});
|
});
|
||||||
res.status(500).json({ error: "Failed to update message status." });
|
res.status(500).json({ error: "Failed to update message status." });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user