From 8109a128984cdc34ac9a23009b8043f5ea149a44 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Mon, 5 May 2025 15:02:44 -0400 Subject: [PATCH] feature/IO-3225-Notifications-1.5: DB Changes --- .../notification-settings-form.component.jsx | 85 ++++++++--- client/src/graphql/bodyshop.queries.js | 2 + client/src/graphql/user.queries.js | 10 ++ hasura/metadata/tables.yaml | 22 +++ server/graphql-client/queries.js | 30 ++++ server/notifications/autoAddWatchers.js | 134 ++++++++++++++++++ server/notifications/eventHandlers.js | 25 +++- server/routes/notificationsRoutes.js | 4 +- 8 files changed, 292 insertions(+), 20 deletions(-) create mode 100644 server/notifications/autoAddWatchers.js diff --git a/client/src/components/notification-settings/notification-settings-form.component.jsx b/client/src/components/notification-settings/notification-settings-form.component.jsx index 859826bbc..f3a307071 100644 --- a/client/src/components/notification-settings/notification-settings-form.component.jsx +++ b/client/src/components/notification-settings/notification-settings-form.component.jsx @@ -1,12 +1,16 @@ import { useMutation, useQuery } from "@apollo/client"; import { useEffect, useState } from "react"; -import { Button, Card, Checkbox, Form, Space, Table } from "antd"; +import { Button, Card, Checkbox, Form, Space, Switch, Table } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectCurrentUser } from "../../redux/user/user.selectors"; import AlertComponent from "../alert/alert.component"; -import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../../graphql/user.queries.js"; +import { + QUERY_NOTIFICATION_SETTINGS, + UPDATE_NOTIFICATION_SETTINGS, + UPDATE_NOTIFICATIONS_AUTOADD +} from "../../graphql/user.queries.js"; import { notificationScenarios } from "../../utils/jobNotificationScenarios.js"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; import PropTypes from "prop-types"; @@ -24,9 +28,11 @@ const NotificationSettingsForm = ({ currentUser }) => { const [form] = Form.useForm(); const [initialValues, setInitialValues] = useState({}); const [isDirty, setIsDirty] = useState(false); + const [autoAddEnabled, setAutoAddEnabled] = useState(false); + const [initialAutoAdd, setInitialAutoAdd] = useState(false); const notification = useNotification(); - // Fetch notification settings. + // Fetch notification settings and notifications_autoadd const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, { fetchPolicy: "network-only", nextFetchPolicy: "network-only", @@ -34,13 +40,16 @@ const NotificationSettingsForm = ({ currentUser }) => { skip: !currentUser }); - const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS); + const [updateNotificationSettings, { loading: savingSettings }] = useMutation(UPDATE_NOTIFICATION_SETTINGS); + const [updateNotificationsAutoAdd, { loading: savingAutoAdd }] = useMutation(UPDATE_NOTIFICATIONS_AUTOADD); - // Populate form with fetched data. + // Populate form with fetched data useEffect(() => { if (data?.associations?.length > 0) { const settings = data.associations[0].notification_settings || {}; - // Ensure each scenario has an object with { app, email, fcm }. + const autoAdd = data.associations[0].notifications_autoadd ?? false; + + // Ensure each scenario has an object with { app, email, fcm } const formattedValues = notificationScenarios.reduce((acc, scenario) => { acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false }; return acc; @@ -48,32 +57,66 @@ const NotificationSettingsForm = ({ currentUser }) => { setInitialValues(formattedValues); form.setFieldsValue(formattedValues); - setIsDirty(false); // Reset dirty state when new data loads. + setAutoAddEnabled(autoAdd); + setInitialAutoAdd(autoAdd); + setIsDirty(false); // Reset dirty state when new data loads } }, [data, form]); + // Handle toggle of notifications_autoadd + const handleAutoAddToggle = async (checked) => { + if (data?.associations?.length > 0) { + const userId = data.associations[0].id; + try { + const result = await updateNotificationsAutoAdd({ + variables: { id: userId, autoadd: checked } + }); + if (!result?.errors) { + setAutoAddEnabled(checked); + setInitialAutoAdd(checked); + notification.success({ message: t("notifications.labels.auto-add-success") }); + setIsDirty(false); // Reset dirty state if only auto-add was changed + } else { + throw new Error("Failed to update auto-add setting"); + } + } catch (err) { + setAutoAddEnabled(!checked); // Revert on error + notification.error({ message: t("notifications.labels.auto-add-failure") }); + } + } + }; + + // Handle save of notification settings const handleSave = async (values) => { if (data?.associations?.length > 0) { const userId = data.associations[0].id; - // Save the updated notification settings. - const result = await updateNotificationSettings({ variables: { id: userId, ns: values } }); - if (!result?.errors) { - notification.success({ message: t("notifications.labels.notification-settings-success") }); - setInitialValues(values); - setIsDirty(false); - } else { + try { + const result = await updateNotificationSettings({ variables: { id: userId, ns: values } }); + if (!result?.errors) { + notification.success({ message: t("notifications.labels.notification-settings-success") }); + setInitialValues(values); + setIsDirty(false); + } else { + throw new Error("Failed to update notification settings"); + } + } catch (err) { notification.error({ message: t("notifications.labels.notification-settings-failure") }); } } }; - // Mark the form as dirty on any manual change. + // Mark the form as dirty on any manual change const handleFormChange = () => { setIsDirty(true); }; + // Check if auto-add has changed + const isAutoAddDirty = autoAddEnabled !== initialAutoAdd; + + // Handle reset of form and auto-add const handleReset = () => { form.setFieldsValue(initialValues); + setAutoAddEnabled(initialAutoAdd); setIsDirty(false); }; @@ -139,11 +182,17 @@ const NotificationSettingsForm = ({ currentUser }) => { title={t("notifications.labels.notificationscenarios")} extra={ - - - diff --git a/client/src/graphql/bodyshop.queries.js b/client/src/graphql/bodyshop.queries.js index 59651017d..7faff13a2 100644 --- a/client/src/graphql/bodyshop.queries.js +++ b/client/src/graphql/bodyshop.queries.js @@ -141,6 +141,7 @@ export const QUERY_BODYSHOP = gql` use_paint_scale_data intellipay_config md_ro_guard + notification_followers employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) { id name @@ -271,6 +272,7 @@ export const UPDATE_SHOP = gql` md_tasks_presets intellipay_config md_ro_guard + notification_followers employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) { id name diff --git a/client/src/graphql/user.queries.js b/client/src/graphql/user.queries.js index 266059c09..c308453ee 100644 --- a/client/src/graphql/user.queries.js +++ b/client/src/graphql/user.queries.js @@ -91,6 +91,7 @@ export const QUERY_NOTIFICATION_SETTINGS = gql` associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) { id notification_settings + notifications_autoadd } } `; @@ -103,3 +104,12 @@ export const UPDATE_NOTIFICATION_SETTINGS = gql` } } `; + +export const UPDATE_NOTIFICATIONS_AUTOADD = gql` + mutation UPDATE_NOTIFICATIONS_AUTOADD($id: uuid!, $autoadd: Boolean!) { + update_associations_by_pk(pk_columns: { id: $id }, _set: { notifications_autoadd: $autoadd }) { + id + notifications_autoadd + } + } +`; diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index ff5950f13..e6f9041d2 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -4598,6 +4598,28 @@ template_engine: Kriti url: '{{$base_url}}/notifications/events/handleJobsChange' version: 2 + - name: notifications_jobs_autoadd + definition: + enable_manual: false + insert: + columns: '*' + retry_conf: + interval_sec: 10 + num_retries: 0 + timeout_sec: 60 + webhook_from_env: HASURA_API_URL + headers: + - name: event-secret + value_from_env: EVENT_SECRET + request_transform: + body: + action: transform + template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n" + method: POST + query_params: {} + template_engine: Kriti + url: '{{$base_url}}/notifications/events/handleAutoAdd' + version: 2 - name: os_jobs definition: delete: diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 7f358e95d..027467b10 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2928,3 +2928,33 @@ exports.INSERT_NEW_DOCUMENT = ` } } `; + +exports.GET_AUTOADD_NOTIFICATION_USERS = ` + query GET_AUTOADD_NOTIFICATION_USERS($shopId: uuid!) { + bodyshops_by_pk(id: $shopId) { + id + notification_followers + } + associations(where: { + _and: [ + { shopid: { _eq: $shopId } }, + { active: { _eq: true } }, + { notifications_autoadd: { _eq: true } } + ] + }) { + id + useremail + } + } +`; + +exports.INSERT_JOB_WATCHERS = ` + mutation INSERT_JOB_WATCHERS($watchers: [job_watchers_insert_input!]!) { + insert_job_watchers(objects: $watchers) { + affected_rows + returning { + user_email + } + } + } +`; diff --git a/server/notifications/autoAddWatchers.js b/server/notifications/autoAddWatchers.js new file mode 100644 index 000000000..aebf42f49 --- /dev/null +++ b/server/notifications/autoAddWatchers.js @@ -0,0 +1,134 @@ +/** + * @module autoAddWatchers + * @description + * This module handles automatically adding watchers to new jobs based on the notifications_autoadd + * boolean field in the associations table and the notification_followers JSON field in the bodyshops table. + * It ensures users are not added twice and logs the process. + */ + +const { client: gqlClient } = require("../graphql-client/graphql-client"); +const queries = require("../graphql-client/queries"); +const { isEmpty } = require("lodash"); + +// If true, the user who commits the action will NOT receive notifications; if false, they will. +const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "false"; + +/** + * Adds watchers to a new job based on notifications_autoadd and notification_followers. + * + * @param {Object} req - The request object containing event data and logger. + * @returns {Promise} Resolves when watchers are added or if no action is needed. + * @throws {Error} If critical data (e.g., jobId, shopId) is missing. + */ +const autoAddWatchers = async (req) => { + const { event, trigger } = req.body; + const { logger } = req; + + // Validate that this is an INSERT event + if (trigger?.name !== "notifications_jobs_autoadd" || event.op !== "INSERT" || event.data.old) { + logger.log("Invalid event for auto-add watchers, skipping", "info", "notifications"); + return; + } + + const jobId = event?.data?.new?.id; + const shopId = event?.data?.new?.shopid; + const roNumber = event?.data?.new?.ro_number || "unknown"; + + if (!jobId || !shopId) { + throw new Error(`Missing jobId (${jobId}) or shopId (${shopId}) for auto-add watchers`); + } + + const hasuraUserRole = event?.session_variables?.["x-hasura-role"]; + const hasuraUserId = event?.session_variables?.["x-hasura-user-id"]; + + try { + // Fetch auto-add users and notification followers + const autoAddData = await gqlClient.request(queries.GET_AUTOADD_NOTIFICATION_USERS, { shopId }); + + // Get users with notifications_autoadd: true + const autoAddUsers = + autoAddData?.associations?.map((assoc) => ({ + email: assoc.useremail, + associationId: assoc.id + })) || []; + + // Get users from notification_followers (array of association IDs) + const notificationFollowers = autoAddData?.bodyshops_by_pk?.notification_followers || []; + let followerEmails = []; + if (notificationFollowers.length > 0) { + // Fetch associations for notification_followers + const followerAssociations = await gqlClient.request(queries.GET_NOTIFICATION_ASSOCIATIONS, { + emails: [], // Filter by association IDs + shopid: shopId + }); + followerEmails = followerAssociations.associations + .filter((assoc) => notificationFollowers.includes(assoc.id)) + .map((assoc) => ({ + email: assoc.useremail, + associationId: assoc.id + })); + } + + // Combine and deduplicate emails (use email as the unique key) + const usersToAdd = [...autoAddUsers, ...followerEmails].reduce((acc, user) => { + if (!acc.some((u) => u.email === user.email)) { + acc.push(user); + } + return acc; + }, []); + + if (isEmpty(usersToAdd)) { + logger.log(`No users to auto-add for jobId "${jobId}" (RO: ${roNumber})`, "info", "notifications"); + return; + } + + // Check existing watchers to avoid duplicates + const existingWatchersData = await gqlClient.request(queries.GET_JOB_WATCHERS, { jobid: jobId }); + const existingWatcherEmails = existingWatchersData?.job_watchers?.map((w) => w.user_email) || []; + + // Filter out already existing watchers and optionally the user who created the job + const newWatchers = usersToAdd + .filter((user) => !existingWatcherEmails.includes(user.email)) + .filter((user) => { + if (FILTER_SELF_FROM_WATCHERS && hasuraUserRole === "user") { + // Fetch user email for hasuraUserId to compare + const userData = existingWatchersData?.job_watchers?.find((w) => w.user?.authid === hasuraUserId); + return userData ? user.email !== userData.user_email : true; + } + return true; + }) + .map((user) => ({ + jobid: jobId, + user_email: user.email + })); + + if (isEmpty(newWatchers)) { + logger.log( + `No new watchers to add after filtering for jobId "${jobId}" (RO: ${roNumber})`, + "info", + "notifications" + ); + return; + } + + // Insert new watchers + await gqlClient.request(queries.INSERT_JOB_WATCHERS, { watchers: newWatchers }); + logger.log( + `Added ${newWatchers.length} auto-add watchers for jobId "${jobId}" (RO: ${roNumber})`, + "info", + "notifications", + null, + { addedEmails: newWatchers.map((w) => w.user_email) } + ); + } catch (error) { + logger.log("Error adding auto-add watchers", "error", "notifications", null, { + message: error?.message, + stack: error?.stack, + jobId, + roNumber + }); + throw error; // Re-throw to ensure the error is logged in the handler + } +}; + +module.exports = { autoAddWatchers }; diff --git a/server/notifications/eventHandlers.js b/server/notifications/eventHandlers.js index eeb86981d..5af7db500 100644 --- a/server/notifications/eventHandlers.js +++ b/server/notifications/eventHandlers.js @@ -6,6 +6,7 @@ */ const scenarioParser = require("./scenarioParser"); +const { autoAddWatchers } = require("./autoAddWatchers"); // New module /** * Processes a notification event by invoking the scenario parser. @@ -185,6 +186,27 @@ const handlePartsDispatchChange = (req, res) => res.status(200).json({ message: */ const handlePartsOrderChange = (req, res) => res.status(200).json({ message: "Parts Order change handled." }); +/** + * Handle auto-add watchers for new jobs. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + * @returns {Promise} JSON response with a success message. + */ +const handleAutoAddWatchers = async (req, res) => { + const { logger } = req; + + // Call autoAddWatchers but don't await it; log any error that occurs. + autoAddWatchers(req).catch((error) => { + logger.log("auto-add-watchers-error", "error", "notifications", null, { + message: error?.message, + stack: error?.stack + }); + }); + + return res.status(200).json({ message: "Auto-Add Watchers Event Handled." }); +}; + module.exports = { handleJobsChange, handleBillsChange, @@ -195,5 +217,6 @@ module.exports = { handlePartsOrderChange, handlePaymentsChange, handleTasksChange, - handleTimeTicketsChange + handleTimeTicketsChange, + handleAutoAddWatchers }; diff --git a/server/routes/notificationsRoutes.js b/server/routes/notificationsRoutes.js index 0d47882b1..9e3709a79 100644 --- a/server/routes/notificationsRoutes.js +++ b/server/routes/notificationsRoutes.js @@ -12,7 +12,8 @@ const { handleNotesChange, handlePaymentsChange, handleDocumentsChange, - handleJobLinesChange + handleJobLinesChange, + handleAutoAddWatchers } = require("../notifications/eventHandlers"); const router = express.Router(); @@ -33,5 +34,6 @@ router.post("/events/handleNotesChange", eventAuthorizationMiddleware, handleNot router.post("/events/handlePaymentsChange", eventAuthorizationMiddleware, handlePaymentsChange); router.post("/events/handleDocumentsChange", eventAuthorizationMiddleware, handleDocumentsChange); router.post("/events/handleJobLinesChange", eventAuthorizationMiddleware, handleJobLinesChange); +router.post("/events/handleAutoAdd", eventAuthorizationMiddleware, handleAutoAddWatchers); module.exports = router;