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