feature/IO-3096-GlobalNotifications - Checkpoint - Fix user getting all bodyshop notifications (now by associationId), fix regression in 'Assigned To' scenario.

This commit is contained in:
Dave Richer
2025-02-26 13:11:49 -05:00
parent b86309e74b
commit 0767e290f4
8 changed files with 99 additions and 65 deletions

View File

@@ -49,7 +49,7 @@ import { useState, useEffect } from "react";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js"; import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { NotificationCenterContainer } from "../notification-center/notification-center.container.jsx"; import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
// Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus // Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus
@@ -129,27 +129,33 @@ function Header({
const { isConnected } = useSocket(bodyshop); const { isConnected } = useSocket(bodyshop);
const [notificationVisible, setNotificationVisible] = useState(false); const [notificationVisible, setNotificationVisible] = useState(false);
const userAssociationId = bodyshop?.associations?.[0]?.id;
const { const {
data: unreadData, data: unreadData,
refetch: refetchUnread, refetch: refetchUnread,
loading: unreadLoading loading: unreadLoading
} = useQuery(GET_UNREAD_COUNT, { } = useQuery(GET_UNREAD_COUNT, {
variables: { associationid: userAssociationId },
fetchPolicy: "network-only", fetchPolicy: "network-only",
pollInterval: isConnected ? 0 : 30000 // Poll only if socket is down pollInterval: isConnected ? 0 : 30000, // Poll only if socket is down
skip: !userAssociationId // Skip query if no userAssociationId
}); });
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0; const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
// Initial fetch and socket status handling // Initial fetch and socket status handling
useEffect(() => { useEffect(() => {
refetchUnread(); if (userAssociationId) {
}, [refetchUnread]); refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
}
}, [refetchUnread, userAssociationId]);
useEffect(() => { useEffect(() => {
if (!isConnected && !unreadLoading) { if (!isConnected && !unreadLoading && userAssociationId) {
refetchUnread(); refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
} }
}, [isConnected, unreadLoading, refetchUnread]); }, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
const handleNotificationClick = (e) => { const handleNotificationClick = (e) => {
setNotificationVisible(!notificationVisible); setNotificationVisible(!notificationVisible);

View File

@@ -1,10 +1,11 @@
// notification-center.container.jsx import { useCallback, useEffect, useMemo, useState } from "react";
import { useState, useEffect, useCallback } from "react"; import { useMutation, useQuery } from "@apollo/client";
import { useQuery, useMutation } from "@apollo/client";
import { connect } from "react-redux"; import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component"; import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS, MARK_ALL_NOTIFICATIONS_READ } from "../../graphql/notifications.queries"; import { GET_NOTIFICATIONS, MARK_ALL_NOTIFICATIONS_READ } from "../../graphql/notifications.queries";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
export function NotificationCenterContainer({ visible, onClose, bodyshop }) { export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [showUnreadOnly, setShowUnreadOnly] = useState(false);
@@ -12,6 +13,16 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { isConnected } = useSocket(); const { isConnected } = useSocket();
const userAssociationId = bodyshop?.associations?.[0]?.id;
const baseWhereClause = useMemo(() => {
return { associationid: { _eq: userAssociationId } };
}, [userAssociationId]);
const whereClause = useMemo(() => {
return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
}, [baseWhereClause, showUnreadOnly]);
const { const {
data, data,
fetchMore, fetchMore,
@@ -22,12 +33,12 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
variables: { variables: {
limit: 20, limit: 20,
offset: 0, offset: 0,
where: showUnreadOnly ? { read: { _is_null: true } } : {} where: whereClause
}, },
fetchPolicy: "cache-and-network", fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
pollInterval: isConnected ? 0 : 30000, pollInterval: isConnected ? 0 : 30000,
skip: false, skip: !userAssociationId, // Skip query if no userAssociationId
onError: (err) => { onError: (err) => {
setError(err.message); setError(err.message);
console.error("GET_NOTIFICATIONS error:", err); console.error("GET_NOTIFICATIONS error:", err);
@@ -36,13 +47,14 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
}); });
const [markAllReadMutation, { error: mutationError }] = useMutation(MARK_ALL_NOTIFICATIONS_READ, { const [markAllReadMutation, { error: mutationError }] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
variables: { associationid: userAssociationId },
update: (cache, { data: mutationData }) => { update: (cache, { data: mutationData }) => {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
cache.modify({ cache.modify({
fields: { fields: {
notifications(existing = [], { readField }) { notifications(existing = [], { readField }) {
return existing.map((notif) => { return existing.map((notif) => {
if (readField("read", notif) === null) { if (readField("read", notif) === null && readField("associationid", notif) === userAssociationId) {
return { ...notif, read: timestamp }; return { ...notif, read: timestamp };
} }
return notif; return notif;
@@ -60,7 +72,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
variables: { variables: {
limit: 20, limit: 20,
offset: 0, offset: 0,
where: showUnreadOnly ? { read: { _is_null: true } } : {} where: whereClause
} }
}); });
@@ -70,7 +82,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
variables: { variables: {
limit: 20, limit: 20,
offset: 0, offset: 0,
where: showUnreadOnly ? { read: { _is_null: true } } : {} where: whereClause
}, },
data: { data: {
notifications: cachedNotifications.notifications.map((notif) => notifications: cachedNotifications.notifications.map((notif) =>
@@ -102,21 +114,19 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
scenarioMeta = {}; scenarioMeta = {};
} }
if (!Array.isArray(scenarioText)) scenarioText = [scenarioText]; if (!Array.isArray(scenarioText)) scenarioText = [scenarioText];
// Derive RO number from scenario_meta or assume it's available in notif const roNumber = notif.job.ro_number;
const roNumber = notif.job.ro_number || "RO Not Found"; // Adjust based on your data structure
if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta]; if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta];
const processed = { return {
id: notif.id, id: notif.id,
jobid: notif.jobid, jobid: notif.jobid,
associationid: notif.associationid, associationid: notif.associationid,
scenarioText, scenarioText,
scenarioMeta, scenarioMeta,
roNumber, // Add RO number to notification object roNumber,
created_at: notif.created_at, created_at: notif.created_at,
read: notif.read, read: notif.read,
__typename: notif.__typename __typename: notif.__typename
}; };
return processed;
}) })
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setNotifications(processedNotifications); setNotifications(processedNotifications);
@@ -133,7 +143,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!loading && data?.notifications.length) { if (!loading && data?.notifications.length) {
fetchMore({ fetchMore({
variables: { offset: data.notifications.length }, variables: { offset: data.notifications.length, where: whereClause },
updateQuery: (prev, { fetchMoreResult }) => { updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev; if (!fetchMoreResult) return prev;
return { return {
@@ -145,7 +155,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
console.error("Fetch more error:", err); console.error("Fetch more error:", err);
}); });
} }
}, [data?.notifications?.length, fetchMore, loading]); }, [data?.notifications?.length, fetchMore, loading, whereClause]);
const handleToggleUnreadOnly = (value) => { const handleToggleUnreadOnly = (value) => {
setShowUnreadOnly(value); setShowUnreadOnly(value);
@@ -157,7 +167,12 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
setNotifications((prev) => { setNotifications((prev) => {
const updatedNotifications = prev.map((notif) => const updatedNotifications = prev.map((notif) =>
notif.read === null ? { ...notif, read: timestamp } : notif notif.read === null && notif.associationid === userAssociationId
? {
...notif,
read: timestamp
}
: notif
); );
return [...updatedNotifications]; return [...updatedNotifications];
}); });
@@ -165,7 +180,6 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`)); .catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`));
}; };
// TODO Tinker
useEffect(() => { useEffect(() => {
if (visible && !isConnected) { if (visible && !isConnected) {
refetch(); refetch();
@@ -187,4 +201,8 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
); );
} }
export default connect((state) => ({ bodyshop: state.user.bodyshop }), null)(NotificationCenterContainer); const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
export default connect(mapStateToProps, null)(NotificationCenterContainer);

View File

@@ -1,4 +1,3 @@
// contexts/SocketIO/socketContext.jsx
import React, { createContext, useContext, useEffect, useRef, useState } from "react"; import React, { createContext, useContext, useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client"; import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils"; import { auth } from "../../firebase/firebase.utils";
@@ -15,10 +14,11 @@ export const SocketProvider = ({ children, bodyshop }) => {
const [clientId, setClientId] = useState(null); const [clientId, setClientId] = useState(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const notification = useNotification(); const notification = useNotification();
const userAssociationId = bodyshop?.associations?.[0]?.id;
useEffect(() => { useEffect(() => {
const initializeSocket = async (token) => { const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id || socketRef.current) return; // Prevent multiple instances if (!bodyshop || !bodyshop.id || socketRef.current) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : ""; const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, { const socketInstance = SocketIO(endpoint, {
@@ -41,7 +41,6 @@ export const SocketProvider = ({ children, bodyshop }) => {
default: default:
break; break;
} }
if (!import.meta.env.DEV) return;
}; };
const handleConnect = () => { const handleConnect = () => {
@@ -87,6 +86,10 @@ export const SocketProvider = ({ children, bodyshop }) => {
const handleNotification = (data) => { const handleNotification = (data) => {
const { jobId, jobRoNumber, notificationId, associationId, notifications } = data; const { jobId, jobRoNumber, notificationId, associationId, notifications } = data;
// Filter out notifications not matching the user's associationId
// Technically not required.
if (associationId !== userAssociationId) return;
const newNotification = { const newNotification = {
__typename: "notifications", __typename: "notifications",
id: notificationId, id: notificationId,
@@ -102,7 +105,11 @@ export const SocketProvider = ({ children, bodyshop }) => {
} }
}; };
const baseVariables = { limit: 20, offset: 0, where: {} }; const baseVariables = {
limit: 20,
offset: 0,
where: { associationid: { _eq: userAssociationId } }
};
try { try {
const existingNotifications = const existingNotifications =
@@ -126,8 +133,10 @@ export const SocketProvider = ({ children, bodyshop }) => {
broadcast: true broadcast: true
}); });
// Handle showUnreadOnly case const unreadVariables = {
const unreadVariables = { ...baseVariables, where: { read: { _is_null: true } } }; ...baseVariables,
where: { ...baseVariables.where, read: { _is_null: true } }
};
const unreadNotifications = const unreadNotifications =
client.cache.readQuery({ client.cache.readQuery({
query: GET_NOTIFICATIONS, query: GET_NOTIFICATIONS,
@@ -228,7 +237,7 @@ export const SocketProvider = ({ children, bodyshop }) => {
setIsConnected(false); setIsConnected(false);
} }
}; };
}, [bodyshop, notification]); }, [bodyshop, notification, userAssociationId]);
return ( return (
<SocketContext.Provider value={{ socket: socketRef.current, clientId, isConnected }}> <SocketContext.Provider value={{ socket: socketRef.current, clientId, isConnected }}>

View File

@@ -19,8 +19,8 @@ export const GET_NOTIFICATIONS = gql`
`; `;
export const GET_UNREAD_COUNT = gql` export const GET_UNREAD_COUNT = gql`
query GetUnreadCount { query GetUnreadCount($associationid: uuid!) {
notifications_aggregate(where: { read: { _is_null: true } }) { notifications_aggregate(where: { read: { _is_null: true }, associationid: { _eq: $associationid } }) {
aggregate { aggregate {
count count
} }
@@ -29,8 +29,11 @@ export const GET_UNREAD_COUNT = gql`
`; `;
export const MARK_ALL_NOTIFICATIONS_READ = gql` export const MARK_ALL_NOTIFICATIONS_READ = gql`
mutation MarkAllNotificationsRead { mutation MarkAllNotificationsRead($associationid: uuid!) {
update_notifications(where: { read: { _is_null: true } }, _set: { read: "now()" }) { update_notifications(
where: { read: { _is_null: true }, associationid: { _eq: $associationid } }
_set: { read: "now()" }
) {
affected_rows affected_rows
} }
} }

View File

@@ -2745,3 +2745,17 @@ query GET_NOTIFICATION_ASSOCIATIONS($emails: [String!]!, $shopid: uuid!) {
} }
} }
`; `;
exports.INSERT_NOTIFICATIONS_MUTATION = ` mutation INSERT_NOTIFICATIONS($objects: [notifications_insert_input!]!) {
insert_notifications(objects: $objects) {
affected_rows
returning {
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
}
}
}`;

View File

@@ -1,4 +1,5 @@
const { Queue, Worker } = require("bullmq"); const { Queue, Worker } = require("bullmq");
const { INSERT_NOTIFICATIONS_MUTATION } = require("../../graphql-client/queries");
const graphQLClient = require("../../graphql-client/graphql-client").client; const graphQLClient = require("../../graphql-client/graphql-client").client;
// Base time-related constant in minutes, sourced from environment variable or defaulting to 1 // Base time-related constant in minutes, sourced from environment variable or defaulting to 1
@@ -20,23 +21,6 @@ const RATE_LIMITER_DURATION = APP_CONSOLIDATION_DELAY * 0.1; // 6 seconds (tenth
let addQueue; let addQueue;
let consolidateQueue; let consolidateQueue;
// Updated GraphQL mutation to insert notifications with the new schema
const INSERT_NOTIFICATIONS_MUTATION = `
mutation INSERT_NOTIFICATIONS($objects: [notifications_insert_input!]!) {
insert_notifications(objects: $objects) {
affected_rows
returning {
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
}
}
}
`;
/** /**
* Builds the scenario_text, fcm_text, and scenario_meta for a batch of notifications. * Builds the scenario_text, fcm_text, and scenario_meta for a batch of notifications.
* *
@@ -165,16 +149,16 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
for (const [user, bodyShopData] of Object.entries(allNotifications)) { for (const [user, bodyShopData] of Object.entries(allNotifications)) {
const userRecipients = recipients.filter((r) => r.user === user); const userRecipients = recipients.filter((r) => r.user === user);
const employeeId = userRecipients[0]?.employeeId; const associationId = userRecipients[0]?.associationId;
for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) { for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) {
const { scenario_text, fcm_text, scenario_meta } = buildNotificationContent(notifications); const { scenario_text, fcm_text, scenario_meta } = buildNotificationContent(notifications);
notificationInserts.push({ notificationInserts.push({
jobid: jobId, jobid: jobId,
associationid: employeeId || null, associationid: associationId,
scenario_text: JSON.stringify(scenario_text), // JSONB requires stringified input scenario_text: JSON.stringify(scenario_text),
fcm_text: fcm_text, fcm_text: fcm_text,
scenario_meta: JSON.stringify(scenario_meta) // JSONB requires stringified input scenario_meta: JSON.stringify(scenario_meta)
}); });
notificationIdMap.set(`${user}:${bodyShopId}`, null); notificationIdMap.set(`${user}:${bodyShopId}`, null);
} }
@@ -200,9 +184,8 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
// Emit notifications to users via Socket.io with notification ID // Emit notifications to users via Socket.io with notification ID
for (const [user, bodyShopData] of Object.entries(allNotifications)) { for (const [user, bodyShopData] of Object.entries(allNotifications)) {
const userMapping = await redisHelpers.getUserSocketMapping(user); const userMapping = await redisHelpers.getUserSocketMapping(user);
// Get all recipients for the user and extract the associationId (employeeId)
const userRecipients = recipients.filter((r) => r.user === user); const userRecipients = recipients.filter((r) => r.user === user);
const associationId = userRecipients[0]?.employeeId; const associationId = userRecipients[0]?.associationId;
for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) { for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) {
const notificationId = notificationIdMap.get(`${user}:${bodyShopId}`); const notificationId = notificationIdMap.get(`${user}:${bodyShopId}`);

View File

@@ -8,8 +8,8 @@ const { getJobAssignmentType } = require("./stringHelpers");
*/ */
const populateWatchers = (data, result) => { const populateWatchers = (data, result) => {
data.scenarioWatchers.forEach((recipients) => { data.scenarioWatchers.forEach((recipients) => {
const { user, app, fcm, email, firstName, lastName, employeeId } = recipients; const { user, app, fcm, email, firstName, lastName, employeeId, associationId } = recipients;
if (app === true) result.app.recipients.push({ user, bodyShopId: data.bodyShopId, employeeId }); if (app === true) result.app.recipients.push({ user, bodyShopId: data.bodyShopId, employeeId, associationId });
if (fcm === true) result.fcm.recipients.push(user); if (fcm === true) result.fcm.recipients.push(user);
if (email === true) result.email.recipients.push({ user, firstName, lastName }); if (email === true) result.email.recipients.push({ user, firstName, lastName });
}); });

View File

@@ -31,7 +31,7 @@ const scenarioParser = async (req, jobIdField) => {
const { event, trigger, table } = req.body; const { event, trigger, table } = req.body;
const { logger } = req; const { logger } = req;
// Validate we know what user commited the action that fired the parser // Validate we know what user committed the action that fired the parser
const hasuraUserId = event?.session_variables?.["x-hasura-user-id"]; const hasuraUserId = event?.session_variables?.["x-hasura-user-id"];
// Bail if we don't know // Bail if we don't know
@@ -140,9 +140,10 @@ const scenarioParser = async (req, jobIdField) => {
email: settings.email, email: settings.email,
app: settings.app, app: settings.app,
fcm: settings.fcm, fcm: settings.fcm,
firstName: matchingWatcher ? matchingWatcher.firstName : undefined, firstName: matchingWatcher?.firstName,
lastName: matchingWatcher ? matchingWatcher.lastName : undefined, lastName: matchingWatcher?.lastName,
employeeId: matchingWatcher ? assoc.id : undefined employeeId: matchingWatcher?.employeeId,
associationId: assoc.id
}; };
}) })
})); }));