From 9bb7f647a7afcbfd3633951c2030b5827c8a840f Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 6 Feb 2025 13:36:19 -0500 Subject: [PATCH 001/140] feature/IO-3096-GlobalNotifications - Global Notification Settings on profile page --- .../notification-settings.component.jsx | 107 ++++++++++++++++++ .../profile-my/profile-my.component.jsx | 7 ++ client/src/graphql/user.queries.js | 18 +++ client/src/translations/en_us/common.json | 23 ++++ client/src/utils/jobNotificationScenarios.js | 19 ++++ 5 files changed, 174 insertions(+) create mode 100644 client/src/components/profile-my/notification-settings.component.jsx create mode 100644 client/src/utils/jobNotificationScenarios.js diff --git a/client/src/components/profile-my/notification-settings.component.jsx b/client/src/components/profile-my/notification-settings.component.jsx new file mode 100644 index 000000000..e501549c4 --- /dev/null +++ b/client/src/components/profile-my/notification-settings.component.jsx @@ -0,0 +1,107 @@ +import { useQuery, useMutation } from "@apollo/client"; +import React, { useEffect, useState } from "react"; +import { Button, Card, Form, Switch, Row, Col, Spin } 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 notificationScenarios from "../../utils/jobNotificationScenarios.js"; +import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; + +function NotificationSettingsForm({ currentUser }) { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const [initialValues, setInitialValues] = useState({}); + const [isDirty, setIsDirty] = useState(false); + + // Fetch notification settings + const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, { + fetchPolicy: "network-only", + nextFetchPolicy: "network-only", + variables: { email: currentUser.email }, + skip: !currentUser + }); + + const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS); + + // Populate form with fetched data + useEffect(() => { + if (data?.associations?.length > 0) { + const settings = data.associations[0].notification_settings || {}; + const formattedValues = notificationScenarios.reduce((acc, scenario) => { + acc[scenario] = settings[scenario] ?? false; + return acc; + }, {}); + + setInitialValues(formattedValues); + form.setFieldsValue(formattedValues); + setIsDirty(false); // Reset dirty state when new data loads + } + }, [data, form]); + + const handleSave = async (values) => { + if (data?.associations?.length > 0) { + const userId = data.associations[0].id; + await updateNotificationSettings({ variables: { id: userId, ns: values } }); + setInitialValues(values); + setIsDirty(false); + } + }; + + const handleFormChange = () => { + setIsDirty(true); + }; + + const handleReset = () => { + form.setFieldsValue(initialValues); + setIsDirty(false); + }; + + if (error) return ; + if (loading) return ; + + return ( +
+ + + + + } + > + + {notificationScenarios.map((scenario) => ( + + + + + + ))} + + +
+ ); +} + +// Redux connection +const mapStateToProps = createStructuredSelector({ + currentUser: selectCurrentUser +}); + +export default connect(mapStateToProps)(NotificationSettingsForm); diff --git a/client/src/components/profile-my/profile-my.component.jsx b/client/src/components/profile-my/profile-my.component.jsx index f53646bee..7e8ce078c 100644 --- a/client/src/components/profile-my/profile-my.component.jsx +++ b/client/src/components/profile-my/profile-my.component.jsx @@ -9,6 +9,7 @@ import { selectCurrentUser } from "../../redux/user/user.selectors"; import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import NotificationSettingsForm from "./notification-settings.component.jsx"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser @@ -46,6 +47,8 @@ export default connect( } }; + const handleScenarios = async ({ values }) => {}; + return ( <> @@ -78,6 +81,10 @@ export default connect( + + + +
Date: Thu, 6 Feb 2025 13:38:15 -0500 Subject: [PATCH 002/140] feature/IO-3096-GlobalNotifications - Global Notification Settings on profile page --- .../components/profile-my/notification-settings.component.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/profile-my/notification-settings.component.jsx b/client/src/components/profile-my/notification-settings.component.jsx index e501549c4..328c85cf1 100644 --- a/client/src/components/profile-my/notification-settings.component.jsx +++ b/client/src/components/profile-my/notification-settings.component.jsx @@ -1,6 +1,6 @@ -import { useQuery, useMutation } from "@apollo/client"; +import { useMutation, useQuery } from "@apollo/client"; import React, { useEffect, useState } from "react"; -import { Button, Card, Form, Switch, Row, Col, Spin } from "antd"; +import { Button, Card, Col, Form, Row, Switch } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; From f11d9dd804eb66a2e2adeb3b5ef532d8cf496eb2 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 6 Feb 2025 15:03:07 -0500 Subject: [PATCH 003/140] feature/IO-3096-GlobalNotifications - Watchers - First revision. --- .../jobs-detail-header.component.jsx | 3 +- client/src/graphql/jobs.queries.js | 30 ++++++++ .../job-watcher-toggle.component.jsx | 71 +++++++++++++++++++ .../jobs-detail.page.component.jsx | 12 +++- client/src/translations/en_us/common.json | 7 ++ hasura/metadata/cron_triggers.yaml | 16 ++--- hasura/metadata/tables.yaml | 59 +++++++-------- 7 files changed, 160 insertions(+), 38 deletions(-) create mode 100644 client/src/pages/jobs-detail/job-watcher-toggle.component.jsx diff --git a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx index ffe729b51..943b5aedd 100644 --- a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx +++ b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx @@ -51,6 +51,7 @@ const colSpan = { }; export function JobsDetailHeader({ job, bodyshop, disabled }) { + console.dir({ job }); const { t } = useTranslation(); const [notesClamped, setNotesClamped] = useState(true); const vehicleTitle = `${job.v_model_yr || ""} ${job.v_color || ""} @@ -119,7 +120,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) { {job.cccontracts.map((c, index) => ( - + {`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`} {index !== job.cccontracts.length - 1 ? "," : null} diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index 1310c32da..09829db7e 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -524,6 +524,9 @@ export const GET_JOB_BY_PK = gql` invoice_final_note iouparent job_totals + job_watchers { + user_email + } joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) { act_price act_price_before_ppc @@ -2566,3 +2569,30 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql` } } `; + +export const GET_JOB_WATCHERS = gql` + query GET_JOB_WATCHERS($jobid: uuid!) { + job_watchers(where: { jobid: { _eq: $jobid } }) { + id + user_email + } + } +`; + +export const ADD_JOB_WATCHER = gql` + mutation ADD_JOB_WATCHER($jobid: uuid!, $userEmail: String!) { + insert_job_watchers_one(object: { jobid: $jobid, user_email: $userEmail }) { + id + jobid + user_email + } + } +`; + +export const REMOVE_JOB_WATCHER = gql` + mutation REMOVE_JOB_WATCHER($jobid: uuid!, $userEmail: String!) { + delete_job_watchers(where: { jobid: { _eq: $jobid }, user_email: { _eq: $userEmail } }) { + affected_rows + } + } +`; diff --git a/client/src/pages/jobs-detail/job-watcher-toggle.component.jsx b/client/src/pages/jobs-detail/job-watcher-toggle.component.jsx new file mode 100644 index 000000000..4a043e1ef --- /dev/null +++ b/client/src/pages/jobs-detail/job-watcher-toggle.component.jsx @@ -0,0 +1,71 @@ +import { useCallback, useMemo } from "react"; +import { useMutation, useQuery } from "@apollo/client"; +import { EyeFilled, EyeOutlined } from "@ant-design/icons"; +import { GET_JOB_WATCHERS, ADD_JOB_WATCHER, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js"; +import { Button, Tooltip } from "antd"; +import { useTranslation } from "react-i18next"; + +const JobWatcherToggle = ({ job, currentUser }) => { + const { t } = useTranslation(); + const userEmail = currentUser.email; + const jobid = job.id; + + // Fetch current watchers + const { data, loading } = useQuery(GET_JOB_WATCHERS, { + variables: { jobid } + }); + + // Extract current watchers list + const jobWatchers = useMemo(() => data?.job_watchers || [], [data]); + const isWatching = useMemo(() => !!jobWatchers.find((w) => w.user_email === userEmail), [jobWatchers, userEmail]); + + // Add watcher mutation + const [addWatcher] = useMutation(ADD_JOB_WATCHER, { + variables: { jobid, userEmail }, + refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }] + }); + + // Remove watcher mutation + const [removeWatcher] = useMutation(REMOVE_JOB_WATCHER, { + variables: { jobid, userEmail }, + refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }] + }); + + // Toggle watcher status + const handleToggle = useCallback(() => { + if (!isWatching) { + // Fix: Add if not watching, remove if watching + addWatcher().catch((err) => console.error(`Something went wrong adding a job watcher: ${err.message}`)); + } else { + removeWatcher().catch((err) => console.error(`Something went wrong removing a job watcher: ${err.message}`)); + } + }, [isWatching, addWatcher, removeWatcher]); + + if (loading) { + return ( + + + + {/* List of Watchers */} + + {t("notifications.labels.watching-issue")} + + + {watcherLoading ? ( + + ) : ( + { + const employee = bodyshop.employees.find((e) => e.user_email === watcher.user_email); + const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email; + + return ( + handleRemoveWatcher(watcher.user_email)}> + {t("notifications.actions.remove")} + + ]} + > + } />} + title={{displayName}} + description={watcher.user_email} // Keep the email for reference + /> + + ); + }} + /> + )} + + {/* Employee Search Select (for adding watchers) */} + {t("notifications.labels.add-watchers")} + jobWatchers.every((w) => w.user_email !== e.user_email))} + placeholder={t("production.labels.employeesearch")} + value={selectedWatcher} // Controlled value + onChange={(value) => { + setSelectedWatcher(value); // Update selected state + handleWatcherSelect(value); // Add watcher logic + }} + /> + + ); return ( - - + ]} + > + } />} + title={{displayName}} + description={watcher.user_email} // Keep the email for reference + /> + + ); + }; + // Popover content const popoverContent = (
@@ -88,52 +128,54 @@ const JobWatcherToggle = ({ job, currentUser, bodyshop }) => { > {isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")} - {/* List of Watchers */} {t("notifications.labels.watching-issue")} - - {watcherLoading ? ( - - ) : ( - { - const employee = bodyshop.employees.find((e) => e.user_email === watcher.user_email); - const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email; - - return ( - handleRemoveWatcher(watcher.user_email)}> - {t("notifications.actions.remove")} - - ]} - > - } />} - title={{displayName}} - description={watcher.user_email} // Keep the email for reference - /> - - ); - }} - /> - )} - + {watcherLoading ? : } {/* Employee Search Select (for adding watchers) */} + + {t("notifications.labels.add-watchers")} jobWatchers.every((w) => w.user_email !== e.user_email))} - placeholder={t("production.labels.employeesearch")} + placeholder={t("notifications.labels.employee-search")} value={selectedWatcher} // Controlled value onChange={(value) => { setSelectedWatcher(value); // Update selected state handleWatcherSelect(value); // Add watcher logic }} /> + {/* Divider for UI separation */} + {/* Only show team selection if there are available teams */} + {bodyshop?.employee_teams?.length > 0 && ( + <> + + {t("notifications.labels.add-watchers-team")} + + { const teamMembers = team.employee_team_members From 163978930f471f82481f163b01339082e1dccd2b Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Mon, 3 Mar 2025 23:29:47 -0500 Subject: [PATCH 070/140] feature/IO-3096-GlobalNotifications - Code Review Part 4 --- .../notification-center/notification-center.component.jsx | 2 +- client/src/translations/en_us/common.json | 3 ++- client/src/translations/es/common.json | 3 ++- client/src/translations/fr/common.json | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/components/notification-center/notification-center.component.jsx b/client/src/components/notification-center/notification-center.component.jsx index 9b6b39ea2..5d9371f49 100644 --- a/client/src/components/notification-center/notification-center.component.jsx +++ b/client/src/components/notification-center/notification-center.component.jsx @@ -43,7 +43,7 @@ const NotificationCenterComponent = ({ }} className="ro-number" > - RO #{notification.roNumber} + {t("notifications.labels.ro-number", { ro_number: notification.roNumber })} {day(notification.created_at).fromNow()} diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 53c07fc12..ba256c680 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -3781,7 +3781,8 @@ "new-notification-title": "New Notification:", "show-unread-only": "Show Unread", "mark-all-read": "Mark Read", - "notification-popup-title": "Changes for Job #{{ro_number}}" + "notification-popup-title": "Changes for Job #{{ro_number}}", + "ro-number": "RO #{{ro_number}}" }, "actions": { "remove": "remove" diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 777737a01..521a8681f 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -3781,7 +3781,8 @@ "new-notification-title": "", "show-unread-only": "", "mark-all-read": "", - "notification-popup-title": "" + "notification-popup-title": "", + "ro-number": "" }, "actions": { "remove": "" diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index f4920775b..af75584d3 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -3781,7 +3781,8 @@ "new-notification-title": "", "show-unread-only": "", "mark-all-read": "", - "notification-popup-title": "" + "notification-popup-title": "", + "ro-number": "" }, "actions": { "remove": "" From 1b2269742948db702131ea3ad44789bf850d3484 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 4 Mar 2025 11:21:21 -0500 Subject: [PATCH 071/140] feature/IO-3096-GlobalNotifications - Code Review Part 5 --- server/notifications/scenarioMapper.js | 44 +++++++++++++++++++++++ server/notifications/scenarioParser.js | 48 +++++++++++++++++++------- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/server/notifications/scenarioMapper.js b/server/notifications/scenarioMapper.js index 7b623a517..6a8b59ba3 100644 --- a/server/notifications/scenarioMapper.js +++ b/server/notifications/scenarioMapper.js @@ -71,6 +71,7 @@ const notificationScenarios = [ key: "job-added-to-production", table: "jobs", fields: ["inproduction"], + onlyTruthyValues: ["inproduction"], builder: jobsAddedToProductionBuilder }, { @@ -129,6 +130,19 @@ const notificationScenarios = [ } ]; +/** + * Returns an array of scenarios that match the given event data. + * + * @param {Object} eventData - The parsed event data. + * Expected properties: + * - table: an object with a `name` property (e.g. { name: "tasks", schema: "public" }) + * - changedFieldNames: an array of changed field names (e.g. [ "description", "updated_at" ]) + * - isNew: boolean indicating whether the record is new or updated + * - data: the new data object (used to check field values) + * - (other properties may be added such as jobWatchers, bodyShopId, etc.) + * + * @returns {Array} An array of matching scenario objects. + */ /** * Returns an array of scenarios that match the given event data. * @@ -181,6 +195,36 @@ const getMatchingScenarios = (eventData) => } } + // OnlyTruthyValues logic: + // If onlyTruthyValues is defined, check that the new values of specified fields (or all changed fields if true) + // are truthy. If an array, only check the listed fields, which must be in scenario.fields. + if (Object.prototype.hasOwnProperty.call(scenario, "onlyTruthyValues")) { + let fieldsToCheck; + + if (scenario.onlyTruthyValues === true) { + // If true, check all fields in the scenario that changed + fieldsToCheck = scenario.fields.filter((field) => eventData.changedFieldNames.includes(field)); + } else if (Array.isArray(scenario.onlyTruthyValues) && scenario.onlyTruthyValues.length > 0) { + // If an array, check only the specified fields, ensuring they are in scenario.fields + fieldsToCheck = scenario.onlyTruthyValues.filter( + (field) => scenario.fields.includes(field) && eventData.changedFieldNames.includes(field) + ); + // If no fields in onlyTruthyValues match the scenario’s fields or changed fields, skip this scenario + if (fieldsToCheck.length === 0) { + return false; + } + } else { + // Invalid onlyTruthyValues (not true or a non-empty array), skip this scenario + return false; + } + + // Ensure all fields to check have truthy new values + const allTruthy = fieldsToCheck.every((field) => Boolean(eventData.data[field])); + if (!allTruthy) { + return false; + } + } + return true; }); diff --git a/server/notifications/scenarioParser.js b/server/notifications/scenarioParser.js index 1a5e2bf97..180493880 100644 --- a/server/notifications/scenarioParser.js +++ b/server/notifications/scenarioParser.js @@ -31,7 +31,9 @@ const scenarioParser = async (req, jobIdField) => { const { event, trigger, table } = req.body; const { logger } = req; - // Validate we know what user committed the action that fired the parser + // Step 1: Validate we know what user committed the action that fired the parser + // console.log("Step 1"); + const hasuraUserId = event?.session_variables?.["x-hasura-user-id"]; // Bail if we don't know who started the scenario @@ -45,7 +47,9 @@ const scenarioParser = async (req, jobIdField) => { throw new Error("Missing required request fields: event data, trigger, or table."); } - // Step 1a: Extract just the jobId using the provided jobIdField + // Step 2: Extract just the jobId using the provided jobIdField + // console.log("Step 2"); + let jobId = null; if (jobIdField) { let keyName = jobIdField; @@ -61,7 +65,9 @@ const scenarioParser = async (req, jobIdField) => { return; } - // Step 2: Query job watchers associated with the job ID using GraphQL + // Step 3: Query job watchers associated with the job ID using GraphQL + // console.log("Step 3"); + const watcherData = await gqlClient.request(queries.GET_JOB_WATCHERS, { jobid: jobId }); @@ -85,7 +91,9 @@ const scenarioParser = async (req, jobIdField) => { return; } - // Step 1b: Perform the full event diff now that we know there are watchers + // Step 5: Perform the full event diff now that we know there are watchers + // console.log("Step 5"); + const eventData = await eventParser({ newData: event.data.new, oldData: event.data.old, @@ -94,7 +102,9 @@ const scenarioParser = async (req, jobIdField) => { jobId }); - // Step 3: Extract body shop information from the job data + // Step 6: Extract body shop information from the job data + // console.log("Step 6"); + const bodyShopId = watcherData?.job?.bodyshop?.id; const bodyShopName = watcherData?.job?.bodyshop?.shopname; const jobRoNumber = watcherData?.job?.ro_number; @@ -105,7 +115,9 @@ const scenarioParser = async (req, jobIdField) => { throw new Error("No bodyshop data found for this job."); } - // Step 4: Identify scenarios that match the event data and job context + // Step 7: Identify scenarios that match the event data and job context + // console.log("Step 7"); + const matchingScenarios = getMatchingScenarios({ ...eventData, jobWatchers, @@ -132,7 +144,9 @@ const scenarioParser = async (req, jobIdField) => { matchingScenarios }; - // Step 5: Query notification settings for the job watchers + // Step 8: Query notification settings for the job watchers + // console.log("Step 8"); + const associationsData = await gqlClient.request(queries.GET_NOTIFICATION_ASSOCIATIONS, { emails: jobWatchers.map((x) => x.email), shopid: bodyShopId @@ -148,7 +162,9 @@ const scenarioParser = async (req, jobIdField) => { return; } - // Step 6: Filter scenario watchers based on their enabled notification methods + // Step 9: Filter scenario watchers based on their enabled notification methods + // console.log("Step 9"); + finalScenarioData.matchingScenarios = finalScenarioData.matchingScenarios.map((scenario) => ({ ...scenario, scenarioWatchers: associationsData.associations @@ -186,7 +202,9 @@ const scenarioParser = async (req, jobIdField) => { return; } - // Step 7: Build and collect scenarios to dispatch notifications for + // Step 10: Build and collect scenarios to dispatch notifications for + // console.log("Step 10"); + const scenariosToDispatch = []; for (const scenario of finalScenarioData.matchingScenarios) { @@ -211,7 +229,9 @@ const scenarioParser = async (req, jobIdField) => { continue; } - // Step 8: Filter scenario fields to include only those that changed + // Step 11: Filter scenario fields to include only those that changed + // console.log("Step 11"); + const filteredScenarioFields = scenario.fields?.filter((field) => eventData.changedFieldNames.includes(field)) || []; @@ -242,7 +262,9 @@ const scenarioParser = async (req, jobIdField) => { return; } - // Step 8: Dispatch email notifications to the email queue + // Step 12: Dispatch email notifications to the email queue + // console.log("Step 12"); + const emailsToDispatch = scenariosToDispatch.map((scenario) => scenario?.email); if (!isEmpty(emailsToDispatch)) { dispatchEmailsToQueue({ emailsToDispatch, logger }).catch((e) => @@ -253,7 +275,9 @@ const scenarioParser = async (req, jobIdField) => { ); } - // Step 9: Dispatch app notifications to the app queue + // Step 13: Dispatch app notifications to the app queue + // console.log("Step 13"); + const appsToDispatch = scenariosToDispatch.map((scenario) => scenario?.app); if (!isEmpty(appsToDispatch)) { dispatchAppsToQueue({ appsToDispatch, logger }).catch((e) => From aa073cfd6822d15467af39d78d6386f93d09f6ac Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 4 Mar 2025 11:38:21 -0500 Subject: [PATCH 072/140] IO-3096-GlobalNotifications: Fixed a small typo in emailQueue --- server/notifications/queues/emailQueue.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/notifications/queues/emailQueue.js b/server/notifications/queues/emailQueue.js index dfc385401..8bb788ddd 100644 --- a/server/notifications/queues/emailQueue.js +++ b/server/notifications/queues/emailQueue.js @@ -5,7 +5,7 @@ const { InstanceEndpoints } = require("../../utils/instanceMgr"); const { registerCleanupTask } = require("../../utils/cleanupManager"); const EMAIL_CONSOLIDATION_DELAY_IN_MINS = (() => { - const envValue = process.env?.APP_CONSOLIDATION_DELAY_IN_MINS; + const envValue = process.env?.EMAIL_CONSOLIDATION_DELAY_IN_MINS; const parsedValue = envValue ? parseInt(envValue, 10) : NaN; return isNaN(parsedValue) ? 1 : Math.max(1, parsedValue); // Default to 1, ensure at least 1 })(); From 2b76f8a12d46648d739f8fa0d2d0cc0f48df8ce5 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 4 Mar 2025 11:40:19 -0500 Subject: [PATCH 073/140] IO-3096-GlobalNotifications: Package Updates to match test-AIO --- client/package-lock.json | 34 +++++++++++++++++----------------- client/package.json | 4 ++-- package-lock.json | 25 +++++++++++++------------ package.json | 6 +++--- 4 files changed, 35 insertions(+), 34 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 1b34cc2f5..fbd87949f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -17,7 +17,7 @@ "@reduxjs/toolkit": "^2.6.0", "@sentry/cli": "^2.42.2", "@sentry/react": "^9.3.0", - "@sentry/vite-plugin": "^3.2.1", + "@sentry/vite-plugin": "^3.2.2", "@splitsoftware/splitio-react": "^1.13.0", "@tanem/react-nprogress": "^5.0.53", "@vitejs/plugin-react": "^4.3.4", @@ -92,7 +92,7 @@ "@emotion/babel-plugin": "^11.13.5", "@emotion/react": "^11.14.0", "@eslint/js": "^9.21.0", - "@sentry/webpack-plugin": "^3.2.1", + "@sentry/webpack-plugin": "^3.2.2", "@testing-library/cypress": "^10.0.2", "browserslist": "^4.24.4", "browserslist-to-esbuild": "^2.1.1", @@ -5311,9 +5311,9 @@ } }, "node_modules/@sentry/babel-plugin-component-annotate": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.2.1.tgz", - "integrity": "sha512-tUp2e+CERpRFzTftjPxt7lg4BF0R3K+wGfeJyIqrc0tbJ2y6duT8OD0ArWoOi1g8xQ73NDn1/mEeS8pC+sbjTQ==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.2.2.tgz", + "integrity": "sha512-D+SKQ266ra/wo87s9+UI/rKQi3qhGPCR8eSCDe0VJudhjHsqyNU+JJ5lnIGCgmZaWFTXgdBP/gdr1Iz1zqGs4Q==", "license": "MIT", "engines": { "node": ">= 14" @@ -5336,13 +5336,13 @@ } }, "node_modules/@sentry/bundler-plugin-core": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.2.1.tgz", - "integrity": "sha512-1wId05LXf6LyTeNwqyhSDSWYbYtFT/NQRqq3sW7hcL4nZuAgzT82PSvxeeCgR/D2qXOj7RCYXXZtyWzzo3wtXA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.2.2.tgz", + "integrity": "sha512-YGrtmqQ2jMixccX2slVG/Lw7pCGJL3DGB3clmY9mO8QBEBIN3/gEANiHJVWwRidpUOS/0b7yVVGAdwZ87oPwTg==", "license": "MIT", "dependencies": { "@babel/core": "^7.18.5", - "@sentry/babel-plugin-component-annotate": "3.2.1", + "@sentry/babel-plugin-component-annotate": "3.2.2", "@sentry/cli": "2.42.2", "dotenv": "^16.3.1", "find-up": "^5.0.0", @@ -5546,12 +5546,12 @@ } }, "node_modules/@sentry/vite-plugin": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.2.1.tgz", - "integrity": "sha512-A/R9PAWPkWR6iqbJJ4C9BygcET0HAq5irEKy7xPmzB0mjW5XbDwbhQtHHnb6C1q/JrfzufB3TZWrG2XfrBRazg==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.2.2.tgz", + "integrity": "sha512-WSkHOhZszMrIE9zmx2l4JhMnMlZmN/yAoHyf59pwFLIMctuZak6lNPbTbIFkFHDzIJ9Nut5RAVsw1qjmWc1PTA==", "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "3.2.1", + "@sentry/bundler-plugin-core": "3.2.2", "unplugin": "1.0.1" }, "engines": { @@ -5559,13 +5559,13 @@ } }, "node_modules/@sentry/webpack-plugin": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.2.1.tgz", - "integrity": "sha512-wP/JDljhB9pCFc62rSwWbIglF2Os8FLV68pQuyJnmImM9cjGjlK6UO+qKa2pOLYsmAcnn+t3Bhu77bbzPIStCg==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.2.2.tgz", + "integrity": "sha512-6OkVKNOjKk8P9j7oh6svZ+kEP1i9YIHBC2aGWL2XsgeZTIrMBxJAXtOf+qSrfMAxEtibSroGVOMQc/y3WJTQtg==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "3.2.1", + "@sentry/bundler-plugin-core": "3.2.2", "unplugin": "1.0.1", "uuid": "^9.0.0" }, diff --git a/client/package.json b/client/package.json index 25d6db482..e54a6a098 100644 --- a/client/package.json +++ b/client/package.json @@ -16,7 +16,7 @@ "@reduxjs/toolkit": "^2.6.0", "@sentry/cli": "^2.42.2", "@sentry/react": "^9.3.0", - "@sentry/vite-plugin": "^3.2.1", + "@sentry/vite-plugin": "^3.2.2", "@splitsoftware/splitio-react": "^1.13.0", "@tanem/react-nprogress": "^5.0.53", "@vitejs/plugin-react": "^4.3.4", @@ -127,7 +127,7 @@ "@emotion/babel-plugin": "^11.13.5", "@emotion/react": "^11.14.0", "@eslint/js": "^9.21.0", - "@sentry/webpack-plugin": "^3.2.1", + "@sentry/webpack-plugin": "^3.2.2", "@testing-library/cypress": "^10.0.2", "browserslist": "^4.24.4", "browserslist-to-esbuild": "^2.1.1", diff --git a/package-lock.json b/package-lock.json index 89fb3ea39..ee26aab5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "intuit-oauth": "^4.2.0", "ioredis": "^5.5.0", "json-2-csv": "^5.5.8", - "juice": "^11.0.0", + "juice": "^11.0.1", "lodash": "^4.17.21", "moment": "^2.30.1", "moment-timezone": "^0.5.47", @@ -56,7 +56,7 @@ "redis": "^4.7.0", "rimraf": "^6.0.1", "skia-canvas": "^2.0.2", - "soap": "^1.1.8", + "soap": "^1.1.9", "socket.io": "^4.8.1", "socket.io-adapter": "^2.5.5", "ssh2-sftp-client": "^11.0.0", @@ -75,7 +75,7 @@ "eslint-plugin-react": "^7.37.4", "globals": "^15.15.0", "p-limit": "^3.1.0", - "prettier": "^3.5.2", + "prettier": "^3.5.3", "source-map-explorer": "^2.5.2" }, "engines": { @@ -7738,13 +7738,14 @@ } }, "node_modules/juice": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/juice/-/juice-11.0.0.tgz", - "integrity": "sha512-sGF8hPz9/Wg+YXbaNDqc1Iuoaw+J/P9lBHNQKXAGc9pPNjCd4fyPai0Zxj7MRtdjMr0lcgk5PjEIkP2b8R9F3w==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/juice/-/juice-11.0.1.tgz", + "integrity": "sha512-R3KLud4l/sN9AMmFZs0QY7cugGSiKvPhGyIsufCV5nJ0MjSlngUE7k80TmFeK9I62wOXrjWBtYA1knVs2OkF8w==", "license": "MIT", "dependencies": { "cheerio": "^1.0.0", "commander": "^12.1.0", + "entities": "^4.5.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^7.0.0" @@ -8975,9 +8976,9 @@ } }, "node_modules/prettier": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", - "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", "bin": { @@ -9919,9 +9920,9 @@ } }, "node_modules/soap": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/soap/-/soap-1.1.8.tgz", - "integrity": "sha512-fDNGyGsPkQP3bZX/366Ud5Kpjo9mCMh7ZKYIc3uipBEPPM2ZqCNkv1Z2/w0qpzpYFLL7do8WWwVUAjAwuUe1AQ==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/soap/-/soap-1.1.9.tgz", + "integrity": "sha512-x6wMhwIwGFnMQiV0tLIygERELwpV/EkidUvzjcCPRx0D16YngNL8z7j5+nFad0Fl5irisXbfY2FKzvF9SEjMog==", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index f7265a83e..6f0426a26 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "intuit-oauth": "^4.2.0", "ioredis": "^5.5.0", "json-2-csv": "^5.5.8", - "juice": "^11.0.0", + "juice": "^11.0.1", "lodash": "^4.17.21", "moment": "^2.30.1", "moment-timezone": "^0.5.47", @@ -66,7 +66,7 @@ "redis": "^4.7.0", "rimraf": "^6.0.1", "skia-canvas": "^2.0.2", - "soap": "^1.1.8", + "soap": "^1.1.9", "socket.io": "^4.8.1", "socket.io-adapter": "^2.5.5", "ssh2-sftp-client": "^11.0.0", @@ -85,7 +85,7 @@ "eslint-plugin-react": "^7.37.4", "globals": "^15.15.0", "p-limit": "^3.1.0", - "prettier": "^3.5.2", + "prettier": "^3.5.3", "source-map-explorer": "^2.5.2" } } From fd7850b5510e2ebcbca61426ac6ac2e1c19abcc4 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 4 Mar 2025 11:56:46 -0500 Subject: [PATCH 074/140] IO-3096-GlobalNotifications: Self Watcher env var was not handled correctly --- server/notifications/queues/appQueue.js | 4 ++-- server/notifications/scenarioParser.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/notifications/queues/appQueue.js b/server/notifications/queues/appQueue.js index 2e6316a7d..7403e807c 100644 --- a/server/notifications/queues/appQueue.js +++ b/server/notifications/queues/appQueue.js @@ -138,7 +138,7 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { await pubClient.del(userKey); logger.logger.debug(`Deleted Redis key ${userKey}`); } else { - logger.logger.warn(`No notifications found for ${user} under ${userKey}`); + logger.logger.debug(`No notifications found for ${user} under ${userKey}`); } } @@ -207,7 +207,7 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => { `Sent ${notifications.length} consolidated notifications to ${user} for jobId ${jobId} with notificationId ${notificationId}` ); } else { - logger.logger.warn(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`); + logger.logger.debug(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`); } } } diff --git a/server/notifications/scenarioParser.js b/server/notifications/scenarioParser.js index 180493880..11c9f979e 100644 --- a/server/notifications/scenarioParser.js +++ b/server/notifications/scenarioParser.js @@ -16,7 +16,7 @@ const { dispatchEmailsToQueue } = require("./queues/emailQueue"); const { dispatchAppsToQueue } = require("./queues/appQueue"); // 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 === "true"; +const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "false"; /** * Parses an event and determines matching scenarios for notifications. From 07faa5eec23f4c8b571874fa12652aea4b13fe09 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 4 Mar 2025 17:07:31 -0500 Subject: [PATCH 075/140] IO-3166-Global-Notifications-Part-2 - Checkpoint --- client/src/contexts/SocketIO/useSocket.jsx | 6 +- hasura/metadata/tables.yaml | 46 ++++++++- server/graphql-client/queries.js | 10 ++ server/notifications/scenarioBuilders.js | 1 - server/notifications/scenarioMapper.js | 43 ++++----- server/routes/miscellaneousRoutes.js | 5 + server/utils/ioHelpers.js | 6 +- server/utils/redisHelpers.js | 105 ++++++++++++++++++++- server/web-sockets/redisSocketEvents.js | 3 + server/web-sockets/updateBodyshopCache.js | 36 +++++++ 10 files changed, 230 insertions(+), 31 deletions(-) create mode 100644 server/web-sockets/updateBodyshopCache.js diff --git a/client/src/contexts/SocketIO/useSocket.jsx b/client/src/contexts/SocketIO/useSocket.jsx index a8b28a145..b2f20e459 100644 --- a/client/src/contexts/SocketIO/useSocket.jsx +++ b/client/src/contexts/SocketIO/useSocket.jsx @@ -85,7 +85,11 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser, scenarioNot }); } }, - onError: (err) => console.error("MARK_NOTIFICATION_READ error:", err) + onError: (err) => + console.error("MARK_NOTIFICATION_READ error:", { + message: err?.message, + stack: err?.stack + }) }); const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, { diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 22ad129a2..04ee6d648 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -1127,6 +1127,46 @@ - active: _eq: true check: null + event_triggers: + - name: cache_bodyshop + definition: + enable_manual: false + update: + columns: + - shopname + - md_order_statuses + 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: |- + { + "created_at": {{$body.created_at}}, + "delivery_info": {{$body.delivery_info}}, + "event": { + "data": { + "new": { + "id": {{$body.event.data.new.id}}, + "shopname": {{$body.event.data.new.shopname}}, + "md_order_statuses": {{$body.event.data.new.md_order_statuses}} + } + }, + "op": {{$body.event.op}}, + "session_variables": {{$body.event.session_variables}} + } + } + method: POST + query_params: {} + template_engine: Kriti + url: '{{$base_url}}/bodyshop-cache' + version: 2 - table: name: cccontracts schema: public @@ -3245,6 +3285,7 @@ update: columns: - critical + - status retry_conf: interval_sec: 10 num_retries: 0 @@ -3254,11 +3295,14 @@ - 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}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"jobid\": {{$body.event.data.old.jobid}},\r\n \"critical\": {{$body.event.data.old.critical}},\r\n \"status\": {{$body.event.data.old.status}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"jobid\": {{$body.event.data.new.jobid}},\r\n \"critical\": {{$body.event.data.old.critical}},\r\n \"status\": {{$body.event.data.new.status}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_joblines\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"joblines\"\r\n }\r\n}\r\n" method: POST query_params: {} template_engine: Kriti url: '{{$base_url}}/notifications/events/handleJobLinesChange' - version: 1 + version: 2 - table: name: joblines_status schema: public diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 94c76bb90..542f3b8bd 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2757,3 +2757,13 @@ exports.INSERT_NOTIFICATIONS_MUTATION = ` mutation INSERT_NOTIFICATIONS($object } } }`; + +exports.GET_BODYSHOP_BY_ID = ` + query GET_BODYSHOP_BY_ID($id: uuid!) { + bodyshops_by_pk(id: $id) { + id + md_order_statuses + shopname + } + } +`; diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index 0860f8ad0..7bce7d39a 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -326,7 +326,6 @@ const newNoteAddedBuilder = (data) => { * Builds notification data for new time tickets posted. */ const newTimeTicketPostedBuilder = (data) => { - consoleDir(data); const type = data?.data?.cost_center; const body = `An ${type} time ticket has been posted${data?.data?.flat_rate ? " (Flat Rate)" : ""}.`.trim(); diff --git a/server/notifications/scenarioMapper.js b/server/notifications/scenarioMapper.js index 6a8b59ba3..4e41c0280 100644 --- a/server/notifications/scenarioMapper.js +++ b/server/notifications/scenarioMapper.js @@ -15,6 +15,7 @@ const { supplementImportedBuilder, partMarkedBackOrderedBuilder } = require("./scenarioBuilders"); +const { isFunction } = require("lodash"); /** * An array of notification scenario definitions. @@ -25,9 +26,9 @@ const { * - fields {Array}: Fields to check for changes. * - matchToUserFields {Array}: Fields used to match scenarios to user data. * - onNew {boolean|Array}: Indicates whether the scenario should be triggered on new data. - * - onlyTrue {Array}: Specifies fields that must be true for the scenario to match. * - builder {Function}: A function to handle the scenario. - */ + * - onlyTruthyValues {boolean|Array}: Specifies fields that must have truthy values for the scenario to match. + * */ const notificationScenarios = [ { key: "job-assigned-to-me", @@ -86,7 +87,6 @@ const notificationScenarios = [ builder: newTimeTicketPostedBuilder }, { - // Good test for batching as this will hit multiple scenarios key: "intake-delivery-checklist-completed", table: "jobs", fields: ["intakechecklist", "deliverchecklist"], @@ -109,24 +109,21 @@ const notificationScenarios = [ key: "critical-parts-status-changed", table: "joblines", fields: ["critical"], - onlyTrue: ["critical"], + onlyTruthyValues: ["critical"], builder: criticalPartsStatusChangedBuilder }, + { + key: "part-marked-back-ordered", + table: "joblines", + fields: ["status"], + builder: partMarkedBackOrderedBuilder + }, // -------------- Difficult --------------- // Holding off on this one for now { key: "supplement-imported", builder: supplementImportedBuilder // spans multiple tables, - }, - // This one may be tricky as the jobid is not directly in the event data (this is probably wrong) - // (should otherwise) - // Status needs to mark meta data 'md_backorderd' for example - // Double check Jobid - { - key: "part-marked-back-ordered", - table: "joblines", - builder: partMarkedBackOrderedBuilder } ]; @@ -183,18 +180,6 @@ const getMatchingScenarios = (eventData) => } } - // OnlyTrue logic: - // If a scenario defines an onlyTrue array, then at least one of those fields must have changed - // and its new value (from eventData.data) must be non-falsey. - if (scenario.onlyTrue && Array.isArray(scenario.onlyTrue) && scenario.onlyTrue.length > 0) { - const hasTruthyChange = scenario.onlyTrue.some( - (field) => eventData.changedFieldNames.includes(field) && Boolean(eventData.data[field]) - ); - if (!hasTruthyChange) { - return false; - } - } - // OnlyTruthyValues logic: // If onlyTruthyValues is defined, check that the new values of specified fields (or all changed fields if true) // are truthy. If an array, only check the listed fields, which must be in scenario.fields. @@ -225,6 +210,14 @@ const getMatchingScenarios = (eventData) => } } + // Execute the callback if defined, passing eventData, and filter based on its return value + if (isFunction(scenario?.callback)) { + const shouldInclude = scenario.callback(eventData); + if (!shouldInclude) { + return false; + } + } + return true; }); diff --git a/server/routes/miscellaneousRoutes.js b/server/routes/miscellaneousRoutes.js index 7463e1757..2fa04a552 100644 --- a/server/routes/miscellaneousRoutes.js +++ b/server/routes/miscellaneousRoutes.js @@ -13,6 +13,7 @@ const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLCl const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails"); const { canvastest } = require("../render/canvas-handler"); const { alertCheck } = require("../alerts/alertcheck"); +const updateBodyshopCache = require("../web-sockets/updateBodyshopCache"); const uuid = require("uuid").v4; //Test route to ensure Express is responding. @@ -58,6 +59,7 @@ router.get("/test-logs", eventAuthorizationMiddleware, (req, res) => { return res.status(500).send("Logs tested."); }); + router.get("/wstest", eventAuthorizationMiddleware, (req, res) => { const { ioRedis } = req; ioRedis.to(`bodyshop-broadcast-room:bfec8c8c-b7f1-49e0-be4c-524455f4e582`).emit("new-message-summary", { @@ -137,4 +139,7 @@ router.post("/canvastest", validateFirebaseIdTokenMiddleware, canvastest); // Alert Check router.post("/alertcheck", eventAuthorizationMiddleware, alertCheck); +// Redis Cache Routes +router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache); + module.exports = router; diff --git a/server/utils/ioHelpers.js b/server/utils/ioHelpers.js index a95bd90b0..584d45ce7 100644 --- a/server/utils/ioHelpers.js +++ b/server/utils/ioHelpers.js @@ -1,7 +1,9 @@ const applyIOHelpers = ({ app, api, io, logger }) => { - const getBodyshopRoom = (bodyshopID) => `bodyshop-broadcast-room:${bodyshopID}`; + // Global Bodyshop Room + const getBodyshopRoom = (bodyshopId) => `bodyshop-broadcast-room:${bodyshopId}`; + // Messaging - conversation specific room to handle detailed messages when the user has a conversation open. - const getBodyshopConversationRoom = ({bodyshopId, conversationId}) => + const getBodyshopConversationRoom = ({ bodyshopId, conversationId }) => `bodyshop-conversation-room:${bodyshopId}:${conversationId}`; const ioHelpersAPI = { diff --git a/server/utils/redisHelpers.js b/server/utils/redisHelpers.js index 11a42dc0c..763981962 100644 --- a/server/utils/redisHelpers.js +++ b/server/utils/redisHelpers.js @@ -1,3 +1,39 @@ +const { GET_BODYSHOP_BY_ID } = require("../graphql-client/queries"); +const client = require("../graphql-client/graphql-client").client; + +const BODYSHOP_CACHE_TTL = 3600; // 1 hour + +/** + * Generate a cache key for a bodyshop + * @param bodyshopId + * @returns {`bodyshop-cache:${string}`} + */ +const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`; + +/** + * Fetch bodyshop data from the database + * @param bodyshopId + * @param logger + * @returns {Promise<*>} + */ +const fetchBodyshopFromDB = async (bodyshopId, logger) => { + try { + const response = await client.request(GET_BODYSHOP_BY_ID, { id: bodyshopId }); + const bodyshop = response.bodyshops_by_pk; + if (!bodyshop) { + throw new Error(`Bodyshop with ID ${bodyshopId} not found`); + } + return bodyshop; // Return the full object as-is + } catch (error) { + logger.log("fetch-bodyshop-from-db", "ERROR", "redis", null, { + bodyshopId, + error: error?.message, + stack: error?.stack + }); + throw error; + } +}; + /** * Apply Redis helper functions * @param pubClient @@ -234,6 +270,71 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => { } }; + // Get bodyshop data from Redis or fetch from DB if missing + const getBodyshopFromRedis = async (bodyshopId) => { + const key = getBodyshopCacheKey(bodyshopId); + try { + // Check if data exists in Redis + const cachedData = await pubClient.get(key); + if (cachedData) { + return JSON.parse(cachedData); // Parse and return the full object + } + + // Cache miss: fetch from DB + const bodyshopData = await fetchBodyshopFromDB(bodyshopId, logger); + + // Store in Redis as a single JSON string + const jsonData = JSON.stringify(bodyshopData); + await pubClient.set(key, jsonData); + await pubClient.expire(key, BODYSHOP_CACHE_TTL); + + logger.log("bodyshop-cache-miss", "DEBUG", "redis", null, { + bodyshopId, + action: "Fetched from DB and cached" + }); + + return bodyshopData; // Return the full object + } catch (error) { + logger.log("get-bodyshop-from-redis", "ERROR", "redis", null, { + bodyshopId, + error: error.message + }); + throw error; + } + }; + + // Update or invalidate bodyshop data in Redis + const updateOrInvalidateBodyshopFromRedis = async (bodyshopId, values = null) => { + const key = getBodyshopCacheKey(bodyshopId); + try { + if (!values) { + // Invalidate cache by deleting the key + await pubClient.del(key); + logger.log("bodyshop-cache-invalidate", "DEBUG", "api", "redis", { + bodyshopId, + action: "Cache invalidated" + }); + } else { + // Update cache with the full provided values + const jsonData = JSON.stringify(values); + await pubClient.set(key, jsonData); + await pubClient.expire(key, BODYSHOP_CACHE_TTL); + logger.log("bodyshop-cache-update", "DEBUG", "api", "redis", { + bodyshopId, + action: "Cache updated", + values + }); + } + } catch (error) { + logger.log("update-or-invalidate-bodyshop-from-redis", "ERROR", "api", "redis", { + bodyshopId, + values, + error: error.message + }); + throw error; + } + }; + const api = { setSessionData, getSessionData, @@ -251,7 +352,9 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => { removeUserSocketMapping, getUserSocketMappingByBodyshop, getUserSocketMapping, - refreshUserSocketTTL + refreshUserSocketTTL, + getBodyshopFromRedis, + updateOrInvalidateBodyshopFromRedis }; Object.assign(module.exports, api); diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index 50c7db961..f59723f11 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -14,12 +14,15 @@ const redisSocketEvents = ({ // Socket Auth Middleware const authMiddleware = async (socket, next) => { const { token, bodyshopId } = socket.handshake.auth; + if (!token) { return next(new Error("Authentication error - no authorization token.")); } + if (!bodyshopId) { return next(new Error("Authentication error - no bodyshopId provided.")); } + try { const user = await admin.auth().verifyIdToken(token); socket.user = user; diff --git a/server/web-sockets/updateBodyshopCache.js b/server/web-sockets/updateBodyshopCache.js new file mode 100644 index 000000000..fc330e6b3 --- /dev/null +++ b/server/web-sockets/updateBodyshopCache.js @@ -0,0 +1,36 @@ +/** + * Update or invalidate bodyshop cache + * @param req + * @param res + * @returns {Promise} + */ +const updateBodyshopCache = async (req, res) => { + const { + sessionUtils: { updateOrInvalidateBodyshopFromRedis }, + logger + } = req; + + const { event } = req.body; + const { new: newData } = event.data; + + try { + if (newData && newData.id) { + // Update cache with the full new data object + await updateOrInvalidateBodyshopFromRedis(newData.id, newData); + logger.logger.debug("Bodyshop cache updated successfully."); + } else { + // Invalidate cache if no valid data provided + await updateOrInvalidateBodyshopFromRedis(newData.id); + logger.logger.debug("Bodyshop cache invalidated successfully."); + } + res.status(200).json({ success: true }); + } catch (error) { + logger.log("bodyshop-cache-update-error", "ERROR", "api", "redis", { + message: error?.message, + stack: error?.stack + }); + res.status(500).json({ success: false, error: error.message }); + } +}; + +module.exports = updateBodyshopCache; From 76ec755d079fa12575e31bde6818dcef520cd2df Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 4 Mar 2025 17:50:58 -0500 Subject: [PATCH 076/140] IO-3166-Global-Notifications-Part-2 - Checkpoint --- hasura/metadata/tables.yaml | 2 - server/notifications/scenarioMapper.js | 76 +++++++++++++++----------- server/notifications/scenarioParser.js | 20 ++++--- 3 files changed, 56 insertions(+), 42 deletions(-) diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 04ee6d648..258a6860b 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -3280,8 +3280,6 @@ - name: notifications_joblines definition: enable_manual: false - insert: - columns: '*' update: columns: - critical diff --git a/server/notifications/scenarioMapper.js b/server/notifications/scenarioMapper.js index 4e41c0280..8a3923997 100644 --- a/server/notifications/scenarioMapper.js +++ b/server/notifications/scenarioMapper.js @@ -15,6 +15,7 @@ const { supplementImportedBuilder, partMarkedBackOrderedBuilder } = require("./scenarioBuilders"); +const logger = require("../utils/logger"); const { isFunction } = require("lodash"); /** @@ -28,7 +29,8 @@ const { isFunction } = require("lodash"); * - onNew {boolean|Array}: Indicates whether the scenario should be triggered on new data. * - builder {Function}: A function to handle the scenario. * - onlyTruthyValues {boolean|Array}: Specifies fields that must have truthy values for the scenario to match. - * */ + * - filterCallback {Function}: Optional callback (sync or async) to further filter the scenario based on event data (returns boolean). + */ const notificationScenarios = [ { key: "job-assigned-to-me", @@ -116,7 +118,19 @@ const notificationScenarios = [ key: "part-marked-back-ordered", table: "joblines", fields: ["status"], - builder: partMarkedBackOrderedBuilder + builder: partMarkedBackOrderedBuilder, + filterCallback: async ({ eventData, getBodyshopFromRedis }) => { + try { + const bodyshop = await getBodyshopFromRedis(eventData.bodyShopId); + return eventData?.data?.status !== bodyshop?.md_order_statuses?.default_bo; + } catch (err) { + logger.log("notifications-error-parts-marked-back-ordered", "error", "notifications", "mapper", { + message: err?.message, + stack: err?.stack + }); + return false; + } + } }, // -------------- Difficult --------------- // Holding off on this one for now @@ -129,6 +143,7 @@ const notificationScenarios = [ /** * Returns an array of scenarios that match the given event data. + * Supports asynchronous callbacks for additional filtering. * * @param {Object} eventData - The parsed event data. * Expected properties: @@ -137,28 +152,16 @@ const notificationScenarios = [ * - isNew: boolean indicating whether the record is new or updated * - data: the new data object (used to check field values) * - (other properties may be added such as jobWatchers, bodyShopId, etc.) - * - * @returns {Array} An array of matching scenario objects. + * @param {Function} getBodyshopFromRedis - Function to retrieve bodyshop data from Redis. + * @returns {Promise>} A promise resolving to an array of matching scenario objects. */ -/** - * Returns an array of scenarios that match the given event data. - * - * @param {Object} eventData - The parsed event data. - * Expected properties: - * - table: an object with a `name` property (e.g. { name: "tasks", schema: "public" }) - * - changedFieldNames: an array of changed field names (e.g. [ "description", "updated_at" ]) - * - isNew: boolean indicating whether the record is new or updated - * - data: the new data object (used to check field values) - * - (other properties may be added such as jobWatchers, bodyShopId, etc.) - * - * @returns {Array} An array of matching scenario objects. - */ -const getMatchingScenarios = (eventData) => - notificationScenarios.filter((scenario) => { +const getMatchingScenarios = async (eventData, getBodyshopFromRedis) => { + const matches = []; + for (const scenario of notificationScenarios) { // If eventData has a table, then only scenarios with a table property that matches should be considered. if (eventData.table) { if (!scenario.table || eventData.table.name !== scenario.table) { - return false; + continue; } } @@ -166,9 +169,9 @@ const getMatchingScenarios = (eventData) => // Allow onNew to be either a boolean or an array of booleans. if (Object.prototype.hasOwnProperty.call(scenario, "onNew")) { if (Array.isArray(scenario.onNew)) { - if (!scenario.onNew.includes(eventData.isNew)) return false; + if (!scenario.onNew.includes(eventData.isNew)) continue; } else { - if (eventData.isNew !== scenario.onNew) return false; + if (eventData.isNew !== scenario.onNew) continue; } } @@ -176,7 +179,7 @@ const getMatchingScenarios = (eventData) => if (scenario.fields && scenario.fields.length > 0) { const hasMatchingField = scenario.fields.some((field) => eventData.changedFieldNames.includes(field)); if (!hasMatchingField) { - return false; + continue; } } @@ -196,30 +199,37 @@ const getMatchingScenarios = (eventData) => ); // If no fields in onlyTruthyValues match the scenario’s fields or changed fields, skip this scenario if (fieldsToCheck.length === 0) { - return false; + continue; } } else { // Invalid onlyTruthyValues (not true or a non-empty array), skip this scenario - return false; + continue; } // Ensure all fields to check have truthy new values const allTruthy = fieldsToCheck.every((field) => Boolean(eventData.data[field])); if (!allTruthy) { - return false; + continue; } } - // Execute the callback if defined, passing eventData, and filter based on its return value - if (isFunction(scenario?.callback)) { - const shouldInclude = scenario.callback(eventData); - if (!shouldInclude) { - return false; + // Execute the callback if defined, supporting both sync and async, and filter based on its return value + if (isFunction(scenario?.filterCallback)) { + const shouldFilter = await Promise.resolve( + scenario.filterCallback({ + eventData, + getBodyshopFromRedis + }) + ); + if (shouldFilter) { + continue; } } - return true; - }); + matches.push(scenario); + } + return matches; +}; module.exports = { notificationScenarios, diff --git a/server/notifications/scenarioParser.js b/server/notifications/scenarioParser.js index 11c9f979e..1f6afea03 100644 --- a/server/notifications/scenarioParser.js +++ b/server/notifications/scenarioParser.js @@ -29,7 +29,10 @@ const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "fa */ const scenarioParser = async (req, jobIdField) => { const { event, trigger, table } = req.body; - const { logger } = req; + const { + logger, + sessionUtils: { getBodyshopFromRedis } + } = req; // Step 1: Validate we know what user committed the action that fired the parser // console.log("Step 1"); @@ -118,12 +121,15 @@ const scenarioParser = async (req, jobIdField) => { // Step 7: Identify scenarios that match the event data and job context // console.log("Step 7"); - const matchingScenarios = getMatchingScenarios({ - ...eventData, - jobWatchers, - bodyShopId, - bodyShopName - }); + const matchingScenarios = await getMatchingScenarios( + { + ...eventData, + jobWatchers, + bodyShopId, + bodyShopName + }, + getBodyshopFromRedis + ); // Exit early if no matching scenarios are identified if (isEmpty(matchingScenarios)) { From ec8a413ed112eab9dd749e4d988732f177fc844d Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 4 Mar 2025 17:54:57 -0500 Subject: [PATCH 077/140] IO-3166-Global-Notifications-Part-2 - Checkpoint --- server/notifications/scenarioBuilders.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index 7bce7d39a..361d130a3 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -367,10 +367,7 @@ const partMarkedBackOrderedBuilder = (data) => { bodyShopId: data.bodyShopId, key: "notifications.job.partBackOrdered", body, - variables: { - queuedForParts: data.changedFields.queued_for_parts?.new, - oldQueuedForParts: data.changedFields.queued_for_parts?.old - }, + variables: {}, recipients: [] }, email: { From f8ae6dc5afc845ff6c6a1d65336154abc3e851f5 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 5 Mar 2025 11:07:28 -0500 Subject: [PATCH 078/140] IO-3166-Global-Notifications-Part-2 - Checkpoint --- .../components/header/header.component.jsx | 2 +- .../job-watcher-toggle.component.jsx | 141 +++++++++++ .../job-watcher-toggle.container.jsx | 218 +++++++++++++++++ .../notification-center.component.jsx | 6 +- .../job-watcher-toggle.component.jsx | 231 ------------------ .../jobs-detail.page.component.jsx | 4 +- client/src/translations/en_us/common.json | 3 +- client/src/translations/es/common.json | 3 +- client/src/translations/fr/common.json | 3 +- .../down.sql | 1 + .../up.sql | 2 + server/notifications/stringHelpers.js | 2 +- 12 files changed, 377 insertions(+), 239 deletions(-) create mode 100644 client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx create mode 100644 client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx delete mode 100644 client/src/pages/jobs-detail/job-watcher-toggle.component.jsx create mode 100644 hasura/migrations/1741145815435_create_index_idx_job_watchers_jobid_user_email_unique/down.sql create mode 100644 hasura/migrations/1741145815435_create_index_idx_job_watchers_jobid_user_email_unique/up.sql diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index a5cc39e21..8399634ba 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -656,7 +656,7 @@ function Header({ icon: unreadLoading ? ( ) : ( - + ), diff --git a/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx b/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx new file mode 100644 index 000000000..54edf4465 --- /dev/null +++ b/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx @@ -0,0 +1,141 @@ +import React from "react"; +import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons"; +import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd"; +import { useTranslation } from "react-i18next"; +import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx"; +import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx"; + +const { Text } = Typography; + +export default function JobWatcherToggleComponent({ + jobWatchers, + isWatching, + watcherLoading, + adding, + removing, + open, + setOpen, + selectedWatcher, + setSelectedWatcher, + selectedTeam, + bodyshop, + Enhanced_Payroll, + handleToggleSelf, + handleRemoveWatcher, + handleWatcherSelect, + handleTeamSelect +}) { + const { t } = useTranslation(); + + const handleRenderItem = (watcher) => { + // Check if watcher is defined and has user_email + if (!watcher || !watcher.user_email) { + return null; // Skip rendering invalid watchers + } + + const employee = bodyshop?.employees?.find((e) => e.user_email === watcher.user_email); + const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email; + + return ( + handleRemoveWatcher(watcher.user_email)} + disabled={adding || removing} // Optional: Disable button during mutations + > + {t("notifications.actions.remove")} + + ]} + > + } />} + title={{displayName}} + description={watcher.user_email} + /> + + ); + }; + + const popoverContent = ( +
+ + + + + {t("notifications.labels.watching-issue")} + + {watcherLoading ? ( + + ) : jobWatchers && jobWatchers.length > 0 ? ( + + ) : ( + {t("notifications.labels.no-watchers")} + )} + + + {t("notifications.labels.add-watchers")} + jobWatchers.every((w) => w.user_email !== e.user_email)) || []} + placeholder={t("notifications.labels.employee-search")} + value={selectedWatcher} + onChange={(value) => { + setSelectedWatcher(value); + handleWatcherSelect(value); + }} + /> + {Enhanced_Payroll && bodyshop?.employee_teams?.length > 0 && ( + <> + + {t("notifications.labels.add-watchers-team")} + { - const teamMembers = team.employee_team_members - .map((member) => { - const employee = bodyshop.employees.find((e) => e.id === member.employeeid); - return employee ? employee.user_email : null; - }) - .filter(Boolean); - - return { - value: JSON.stringify(teamMembers), - label: team.name - }; - })} - /> - - )} -
- ); - - return ( - - - - - - - {t("notifications.labels.watching-issue")} - + + : } + size="medium" + onClick={handleToggleSelf} + loading={adding || removing} + > + {isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")} + + ]} + > + + + {t("notifications.labels.watching-issue")} + + + + {watcherLoading ? ( ) : jobWatchers && jobWatchers.length > 0 ? ( @@ -82,8 +92,8 @@ export default function JobWatcherToggleComponent({ ) : ( {t("notifications.labels.no-watchers")} )} - + {t("notifications.labels.add-watchers")} - +