feature/IO-3225-Notifications-1.5: DB Changes
This commit is contained in:
@@ -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={
|
||||
<Space>
|
||||
<Button type="default" onClick={handleReset} disabled={!isDirty}>
|
||||
<Switch
|
||||
checked={autoAddEnabled}
|
||||
onChange={handleAutoAddToggle}
|
||||
loading={savingAutoAdd}
|
||||
checkedChildren={t("notifications.labels.auto-add-on")}
|
||||
unCheckedChildren={t("notifications.labels.auto-add-off")}
|
||||
/>
|
||||
<Button type="default" onClick={handleReset} disabled={!isDirty && !isAutoAddDirty}>
|
||||
{t("general.actions.clear")}
|
||||
</Button>
|
||||
|
||||
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
|
||||
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={savingSettings}>
|
||||
{t("notifications.labels.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
134
server/notifications/autoAddWatchers.js
Normal file
134
server/notifications/autoAddWatchers.js
Normal file
@@ -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<void>} 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 };
|
||||
@@ -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<Object>} JSON response with a success message.
|
||||
*/
|
||||
const handleAutoAddWatchers = async (req, res) => {
|
||||
const { logger } = req;
|
||||
|
||||
// Call autoAddWatchers but don't await it; log any error that occurs.
|
||||
autoAddWatchers(req).catch((error) => {
|
||||
logger.log("auto-add-watchers-error", "error", "notifications", null, {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Auto-Add Watchers Event Handled." });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
handleJobsChange,
|
||||
handleBillsChange,
|
||||
@@ -195,5 +217,6 @@ module.exports = {
|
||||
handlePartsOrderChange,
|
||||
handlePaymentsChange,
|
||||
handleTasksChange,
|
||||
handleTimeTicketsChange
|
||||
handleTimeTicketsChange,
|
||||
handleAutoAddWatchers
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@ const {
|
||||
handleNotesChange,
|
||||
handlePaymentsChange,
|
||||
handleDocumentsChange,
|
||||
handleJobLinesChange
|
||||
handleJobLinesChange,
|
||||
handleAutoAddWatchers
|
||||
} = require("../notifications/eventHandlers");
|
||||
|
||||
const router = express.Router();
|
||||
@@ -33,5 +34,6 @@ router.post("/events/handleNotesChange", eventAuthorizationMiddleware, handleNot
|
||||
router.post("/events/handlePaymentsChange", eventAuthorizationMiddleware, handlePaymentsChange);
|
||||
router.post("/events/handleDocumentsChange", eventAuthorizationMiddleware, handleDocumentsChange);
|
||||
router.post("/events/handleJobLinesChange", eventAuthorizationMiddleware, handleJobLinesChange);
|
||||
router.post("/events/handleAutoAdd", eventAuthorizationMiddleware, handleAutoAddWatchers);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user