feature/IO-3096-GlobalNotifications - Checkpoint - clicking an individual notification will mark it read
This commit is contained in:
@@ -1,13 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery } from "@apollo/client";
|
import { useQuery } 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 {
|
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
|
||||||
GET_NOTIFICATIONS,
|
|
||||||
MARK_ALL_NOTIFICATIONS_READ,
|
|
||||||
MARK_NOTIFICATION_READ,
|
|
||||||
GET_UNREAD_COUNT
|
|
||||||
} from "../../graphql/notifications.queries";
|
|
||||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
@@ -16,7 +11,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
|
|||||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const { isConnected } = useSocket();
|
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
|
||||||
|
|
||||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||||
|
|
||||||
@@ -51,106 +46,6 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [markAllReadMutation, { error: mutationError }] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
|
|
||||||
variables: { associationid: userAssociationId },
|
|
||||||
update: (cache, { data: mutationData }) => {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
cache.modify({
|
|
||||||
fields: {
|
|
||||||
notifications(existing = [], { readField }) {
|
|
||||||
return existing.map((notif) => {
|
|
||||||
if (readField("read", notif) === null && readField("associationid", notif) === userAssociationId) {
|
|
||||||
return { ...notif, read: timestamp };
|
|
||||||
}
|
|
||||||
return notif;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
notifications_aggregate() {
|
|
||||||
return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isConnected) {
|
|
||||||
const cachedNotifications = cache.readQuery({
|
|
||||||
query: GET_NOTIFICATIONS,
|
|
||||||
variables: {
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
where: whereClause
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cachedNotifications?.notifications) {
|
|
||||||
cache.writeQuery({
|
|
||||||
query: GET_NOTIFICATIONS,
|
|
||||||
variables: {
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
where: whereClause
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
notifications: cachedNotifications.notifications.map((notif) =>
|
|
||||||
notif.read === null ? { ...notif, read: timestamp } : notif
|
|
||||||
)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("MARK_ALL_NOTIFICATIONS_READ error:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
|
|
||||||
update: (cache, { data: { update_notifications } }) => {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const updatedNotification = update_notifications.returning[0];
|
|
||||||
|
|
||||||
// Update the notifications list
|
|
||||||
cache.modify({
|
|
||||||
fields: {
|
|
||||||
notifications(existing = [], { readField }) {
|
|
||||||
return existing.map((notif) => {
|
|
||||||
if (readField("id", notif) === updatedNotification.id) {
|
|
||||||
return { ...notif, read: timestamp };
|
|
||||||
}
|
|
||||||
return notif;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the unread count in notifications_aggregate
|
|
||||||
const unreadCountQuery = cache.readQuery({
|
|
||||||
query: GET_UNREAD_COUNT,
|
|
||||||
variables: { associationid: userAssociationId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (unreadCountQuery?.notifications_aggregate?.aggregate?.count > 0) {
|
|
||||||
cache.writeQuery({
|
|
||||||
query: GET_UNREAD_COUNT,
|
|
||||||
variables: { associationid: userAssociationId },
|
|
||||||
data: {
|
|
||||||
notifications_aggregate: {
|
|
||||||
...unreadCountQuery.notifications_aggregate,
|
|
||||||
aggregate: {
|
|
||||||
...unreadCountQuery.notifications_aggregate.aggregate,
|
|
||||||
count: unreadCountQuery.notifications_aggregate.aggregate.count - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("MARK_NOTIFICATION_READ error:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.notifications) {
|
if (data?.notifications) {
|
||||||
const processedNotifications = data.notifications
|
const processedNotifications = data.notifications
|
||||||
@@ -187,10 +82,10 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queryError || mutationError) {
|
if (queryError) {
|
||||||
setError(queryError?.message || mutationError?.message);
|
setError(queryError.message);
|
||||||
}
|
}
|
||||||
}, [queryError, mutationError]);
|
}, [queryError]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (!loading && data?.notifications.length) {
|
if (!loading && data?.notifications.length) {
|
||||||
@@ -213,19 +108,24 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
|
|||||||
setShowUnreadOnly(value);
|
setShowUnreadOnly(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAllRead = () => {
|
const handleMarkAllRead = useCallback(() => {
|
||||||
markAllReadMutation()
|
markAllNotificationsRead()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
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.associationid === userAssociationId ? { ...notif, read: timestamp } : notif
|
notif.read === null && notif.associationid === userAssociationId
|
||||||
|
? {
|
||||||
|
...notif,
|
||||||
|
read: timestamp
|
||||||
|
}
|
||||||
|
: notif
|
||||||
);
|
);
|
||||||
return [...updatedNotifications];
|
return [...updatedNotifications];
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`));
|
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`));
|
||||||
};
|
}, [markAllNotificationsRead, userAssociationId]);
|
||||||
|
|
||||||
const handleNotificationClick = useCallback(
|
const handleNotificationClick = useCallback(
|
||||||
(notificationId) => {
|
(notificationId) => {
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { store } from "../../redux/store";
|
|||||||
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
||||||
import client from "../../utils/GraphQLClient";
|
import client from "../../utils/GraphQLClient";
|
||||||
import { useNotification } from "../Notifications/notificationContext.jsx";
|
import { useNotification } from "../Notifications/notificationContext.jsx";
|
||||||
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries.js";
|
import {
|
||||||
|
GET_NOTIFICATIONS,
|
||||||
|
GET_UNREAD_COUNT,
|
||||||
|
MARK_NOTIFICATION_READ,
|
||||||
|
MARK_ALL_NOTIFICATIONS_READ
|
||||||
|
} from "../../graphql/notifications.queries.js";
|
||||||
|
import { useMutation } from "@apollo/client";
|
||||||
|
|
||||||
const SocketContext = createContext(null);
|
const SocketContext = createContext(null);
|
||||||
|
|
||||||
@@ -16,6 +22,103 @@ export const SocketProvider = ({ children, bodyshop }) => {
|
|||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||||
|
|
||||||
|
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
|
||||||
|
update: (cache, { data: { update_notifications } }) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const updatedNotification = update_notifications.returning[0];
|
||||||
|
|
||||||
|
// Update the notifications list
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
notifications(existing = [], { readField }) {
|
||||||
|
return existing.map((notif) => {
|
||||||
|
if (readField("id", notif) === updatedNotification.id) {
|
||||||
|
return { ...notif, read: timestamp };
|
||||||
|
}
|
||||||
|
return notif;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the unread count in notifications_aggregate
|
||||||
|
const unreadCountQuery = cache.readQuery({
|
||||||
|
query: GET_UNREAD_COUNT,
|
||||||
|
variables: { associationid: userAssociationId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unreadCountQuery?.notifications_aggregate?.aggregate?.count > 0) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_UNREAD_COUNT,
|
||||||
|
variables: { associationid: userAssociationId },
|
||||||
|
data: {
|
||||||
|
notifications_aggregate: {
|
||||||
|
...unreadCountQuery.notifications_aggregate,
|
||||||
|
aggregate: {
|
||||||
|
...unreadCountQuery.notifications_aggregate.aggregate,
|
||||||
|
count: unreadCountQuery.notifications_aggregate.aggregate.count - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.error("MARK_NOTIFICATION_READ error in SocketProvider:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
|
||||||
|
variables: { associationid: userAssociationId },
|
||||||
|
update: (cache) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
notifications(existing = [], { readField }) {
|
||||||
|
return existing.map((notif) => {
|
||||||
|
if (readField("read", notif) === null && readField("associationid", notif) === userAssociationId) {
|
||||||
|
return { ...notif, read: timestamp };
|
||||||
|
}
|
||||||
|
return notif;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
notifications_aggregate() {
|
||||||
|
return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseWhereClause = { associationid: { _eq: userAssociationId } };
|
||||||
|
const cachedNotifications = cache.readQuery({
|
||||||
|
query: GET_NOTIFICATIONS,
|
||||||
|
variables: {
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
where: baseWhereClause
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cachedNotifications?.notifications) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_NOTIFICATIONS,
|
||||||
|
variables: {
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
where: baseWhereClause
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
notifications: cachedNotifications.notifications.map((notif) =>
|
||||||
|
notif.read === null ? { ...notif, read: timestamp } : notif
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.error("MARK_ALL_NOTIFICATIONS_READ error in SocketProvider:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeSocket = async (token) => {
|
const initializeSocket = async (token) => {
|
||||||
if (!bodyshop || !bodyshop.id || socketRef.current) return;
|
if (!bodyshop || !bodyshop.id || socketRef.current) return;
|
||||||
@@ -86,8 +189,6 @@ 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;
|
if (associationId !== userAssociationId) return;
|
||||||
|
|
||||||
const newNotification = {
|
const newNotification = {
|
||||||
@@ -176,7 +277,14 @@ export const SocketProvider = ({ children, bodyshop }) => {
|
|||||||
notification.info({
|
notification.info({
|
||||||
message: "New Notification",
|
message: "New Notification",
|
||||||
description: (
|
description: (
|
||||||
<ul>
|
<ul
|
||||||
|
onClick={() => {
|
||||||
|
markNotificationRead({ variables: { id: notificationId } }).catch((e) =>
|
||||||
|
console.error(`Error marking notification read from info: ${e?.message || ""}`)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
{notifications.map((notif, index) => (
|
{notifications.map((notif, index) => (
|
||||||
<li key={index}>{notif.body}</li>
|
<li key={index}>{notif.body}</li>
|
||||||
))}
|
))}
|
||||||
@@ -237,10 +345,12 @@ export const SocketProvider = ({ children, bodyshop }) => {
|
|||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [bodyshop, notification, userAssociationId]);
|
}, [bodyshop, notification, userAssociationId, markNotificationRead, markAllNotificationsRead]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SocketContext.Provider value={{ socket: socketRef.current, clientId, isConnected }}>
|
<SocketContext.Provider
|
||||||
|
value={{ socket: socketRef.current, clientId, isConnected, markNotificationRead, markAllNotificationsRead }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</SocketContext.Provider>
|
</SocketContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
17
server.js
17
server.js
@@ -232,14 +232,11 @@ const applySocketIO = async ({ server, app }) => {
|
|||||||
pubClient.on("error", (err) => logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis"));
|
pubClient.on("error", (err) => logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis"));
|
||||||
subClient.on("error", (err) => logger.log(`Redis subClient error: ${err}`, "ERROR", "redis"));
|
subClient.on("error", (err) => logger.log(`Redis subClient error: ${err}`, "ERROR", "redis"));
|
||||||
|
|
||||||
process.on("SIGINT", async () => {
|
// Register Redis cleanup
|
||||||
|
registerCleanupTask(async () => {
|
||||||
logger.log("Closing Redis connections...", "INFO", "redis", "api");
|
logger.log("Closing Redis connections...", "INFO", "redis", "api");
|
||||||
try {
|
await Promise.all([pubClient.disconnect(), subClient.disconnect()]);
|
||||||
await Promise.all([pubClient.disconnect(), subClient.disconnect()]);
|
logger.log("Redis connections closed.", "INFO", "redis", "api");
|
||||||
logger.log("Redis connections closed. Process will exit.", "INFO", "redis", "api");
|
|
||||||
} catch (error) {
|
|
||||||
logger.log(`Error closing Redis connections: ${error.message}`, "ERROR", "redis", "api");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ioRedis = new Server(server, {
|
const ioRedis = new Server(server, {
|
||||||
@@ -299,6 +296,12 @@ const applySocketIO = async ({ server, app }) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load Queues for Email and App
|
* Load Queues for Email and App
|
||||||
|
* @param {Object} options - Queue configuration options
|
||||||
|
* @param {Redis.Cluster} options.pubClient - Redis client for publishing
|
||||||
|
* @param {Object} options.logger - Logger instance
|
||||||
|
* @param {Object} options.redisHelpers - Redis helper functions
|
||||||
|
* @param {Server} options.ioRedis - Socket.IO server instance
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
||||||
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
|
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ const notificationScenarios = [
|
|||||||
*
|
*
|
||||||
* @returns {Array<Object>} An array of matching scenario objects.
|
* @returns {Array<Object>} An array of matching scenario objects.
|
||||||
*/
|
*/
|
||||||
function getMatchingScenarios(eventData) {
|
const getMatchingScenarios = (eventData) =>
|
||||||
return notificationScenarios.filter((scenario) => {
|
notificationScenarios.filter((scenario) => {
|
||||||
// If eventData has a table, then only scenarios with a table property that matches should be considered.
|
// If eventData has a table, then only scenarios with a table property that matches should be considered.
|
||||||
if (eventData.table) {
|
if (eventData.table) {
|
||||||
if (!scenario.table || eventData.table.name !== scenario.table) {
|
if (!scenario.table || eventData.table.name !== scenario.table) {
|
||||||
@@ -185,7 +185,6 @@ function getMatchingScenarios(eventData) {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
notificationScenarios,
|
notificationScenarios,
|
||||||
|
|||||||
Reference in New Issue
Block a user