From 9bb7f647a7afcbfd3633951c2030b5827c8a840f Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 6 Feb 2025 13:36:19 -0500 Subject: [PATCH 01/65] 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 02/65] 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 03/65] 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 64/65] 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 65/65] 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) =>