feature/IO-3225-Notifications-1.5: checkpoint

This commit is contained in:
Dave Richer
2025-05-06 13:38:42 -04:00
parent 1dd28af752
commit 020db91105
10 changed files with 79 additions and 39 deletions

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
const { Option } = Select; const { Option } = Select;
//To be used as a form element only. //To be used as a form element only.
const EmployeeSearchSelect = ({ options, ...props }) => { const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -21,12 +21,16 @@ const EmployeeSearchSelect = ({ options, ...props }) => {
{options {options
? options.map((o) => ( ? options.map((o) => (
<Option key={o.id} value={o.id} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}> <Option key={o.id} value={o.id} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
<Space> <Space size="small">
{`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`} {`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
<Tag color="green"> <Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")} {o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag> </Tag>
{o.user_email ? <Tag color="blue">{o.user_email}</Tag> : null} {showEmail && o.user_email ? (
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.user_email}
</Tag>
) : null}
</Space> </Space>
</Option> </Option>
)) ))

View File

@@ -104,6 +104,7 @@ export default function JobWatcherToggleComponent({
} }
placeholder={t("notifications.labels.employee-search")} placeholder={t("notifications.labels.employee-search")}
value={selectedWatcher} value={selectedWatcher}
showEmail={true}
onChange={(value) => { onChange={(value) => {
setSelectedWatcher(value); setSelectedWatcher(value);
handleWatcherSelect(value); handleWatcherSelect(value);

View File

@@ -1,27 +1,52 @@
// shop-info.notifications-autoadd.component.jsx
import React from "react";
import { Form, Typography } from "antd"; import { Form, Typography } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component.jsx"; import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component.jsx";
const { Text, Paragraph } = Typography; const { Text, Paragraph } = Typography;
export default function ShopInfoNotificationsAutoadd({ form, bodyshop }) { export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.id) || []; // Filter employee options to ensure active employees with valid IDs
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.id && typeof e.id === "string") || [];
return ( return (
<div> <div>
<Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph> <Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph>
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text> <Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
{employeeOptions.length > 0 ? ( {employeeOptions.length > 0 ? (
<Form.Item name="notification_followers" rules={[{ type: "array", message: t("general.validation.array") }]}> <Form.Item
name="notification_followers"
rules={[
{
type: "array",
message: t("general.validation.array")
},
{
validator: async (_, value) => {
if (!value || value.length === 0) {
return Promise.resolve(); // Allow empty array
}
const hasInvalid = value.some((id) => id == null || typeof id !== "string" || id.trim() === "");
if (hasInvalid) {
return Promise.reject(new Error(t("bodyshop.fields.notifications.invalid_followers")));
}
return Promise.resolve();
}
}
]}
>
<EmployeeSearchSelectComponent <EmployeeSearchSelectComponent
style={{ minWidth: "100%" }} style={{ minWidth: "100%" }}
mode="multiple" mode="multiple"
options={employeeOptions} options={employeeOptions}
placeholder={t("bodyshop.fields.notifications.placeholder")} placeholder={t("bodyshop.fields.notifications.placeholder")}
showEmail={true}
onChange={(value) => {
// Filter out null or invalid values before passing to Form
const cleanedValue = value?.filter((id) => id != null && typeof id === "string" && id.trim() !== "");
return cleanedValue;
}}
/> />
</Form.Item> </Form.Item>
) : ( ) : (

View File

@@ -651,7 +651,8 @@
"zip_post": "Zip/Postal Code", "zip_post": "Zip/Postal Code",
"notifications": { "notifications": {
"description": "Select employees to automatically follow new jobs and receive notifications for job updates.", "description": "Select employees to automatically follow new jobs and receive notifications for job updates.",
"placeholder": "Search for employees" "placeholder": "Search for employees",
"invalid_followers": "Invalid followers. Please select valid employees."
} }
}, },
"labels": { "labels": {

View File

@@ -651,7 +651,8 @@
"zip_post": "", "zip_post": "",
"notifications": { "notifications": {
"description": "", "description": "",
"placeholder": "" "placeholder": "",
"invalid_followers": ""
} }
}, },
"labels": { "labels": {

View File

@@ -651,7 +651,8 @@
"zip_post": "", "zip_post": "",
"notifications": { "notifications": {
"description": "", "description": "",
"placeholder": "" "placeholder": "",
"invalid_followers": ""
} }
}, },
"labels": { "labels": {

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" alter column "notification_followers" set default json_build_object();

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" alter column "notification_followers" set default json_build_array();

View File

@@ -2765,6 +2765,17 @@ query GET_JOB_WATCHERS($jobid: uuid!) {
} }
`; `;
exports.GET_JOB_WATCHERS_MINIMAL = `
query GET_JOB_WATCHERS_MINIMAL($jobid: uuid!) {
job_watchers(where: { jobid: { _eq: $jobid } }) {
user_email
user {
authid
}
}
}
`;
exports.GET_NOTIFICATION_ASSOCIATIONS = ` exports.GET_NOTIFICATION_ASSOCIATIONS = `
query GET_NOTIFICATION_ASSOCIATIONS($emails: [String!]!, $shopid: uuid!) { query GET_NOTIFICATION_ASSOCIATIONS($emails: [String!]!, $shopid: uuid!) {
associations(where: { associations(where: {
@@ -2802,6 +2813,7 @@ exports.GET_BODYSHOP_BY_ID = `
imexshopid imexshopid
intellipay_config intellipay_config
state state
notification_followers
} }
} }
`; `;
@@ -2931,10 +2943,6 @@ exports.INSERT_NEW_DOCUMENT = `
exports.GET_AUTOADD_NOTIFICATION_USERS = ` exports.GET_AUTOADD_NOTIFICATION_USERS = `
query GET_AUTOADD_NOTIFICATION_USERS($shopId: uuid!) { query GET_AUTOADD_NOTIFICATION_USERS($shopId: uuid!) {
bodyshops_by_pk(id: $shopId) {
id
notification_followers
}
associations(where: { associations(where: {
_and: [ _and: [
{ shopid: { _eq: $shopId } }, { shopid: { _eq: $shopId } },

View File

@@ -7,8 +7,13 @@
*/ */
const { client: gqlClient } = require("../graphql-client/graphql-client"); const { client: gqlClient } = require("../graphql-client/graphql-client");
const queries = require("../graphql-client/queries");
const { isEmpty } = require("lodash"); const { isEmpty } = require("lodash");
const {
GET_JOB_WATCHERS_MINIMAL,
GET_AUTOADD_NOTIFICATION_USERS,
GET_EMPLOYEE_EMAILS,
INSERT_JOB_WATCHERS
} = require("../graphql-client/queries");
// If true, the user who commits the action will NOT receive notifications; if false, they will. // 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"; const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "false";
@@ -22,11 +27,13 @@ const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "fa
*/ */
const autoAddWatchers = async (req) => { const autoAddWatchers = async (req) => {
const { event, trigger } = req.body; const { event, trigger } = req.body;
const { logger } = req; const {
logger,
sessionUtils: { getBodyshopFromRedis }
} = req;
// Validate that this is an INSERT event // Validate that this is an INSERT event, bail
if (trigger?.name !== "notifications_jobs_autoadd" || event.op !== "INSERT" || event.data.old) { if (trigger?.name !== "notifications_jobs_autoadd" || event.op !== "INSERT" || event.data.old) {
logger.log("Invalid event for auto-add watchers, skipping", "info", "notifications");
return; return;
} }
@@ -42,8 +49,12 @@ const autoAddWatchers = async (req) => {
const hasuraUserId = event?.session_variables?.["x-hasura-user-id"]; const hasuraUserId = event?.session_variables?.["x-hasura-user-id"];
try { try {
// Fetch auto-add users and notification followers // Fetch bodyshop data from Redis
const autoAddData = await gqlClient.request(queries.GET_AUTOADD_NOTIFICATION_USERS, { shopId }); const bodyshopData = await getBodyshopFromRedis(shopId);
const notificationFollowers = bodyshopData?.notification_followers || [];
// Fetch auto-add users from associations
const autoAddData = await gqlClient.request(GET_AUTOADD_NOTIFICATION_USERS, { shopId });
// Get users with notifications_autoadd: true // Get users with notifications_autoadd: true
const autoAddUsers = const autoAddUsers =
@@ -53,12 +64,11 @@ const autoAddWatchers = async (req) => {
})) || []; })) || [];
// Get users from notification_followers (array of employee IDs) // Get users from notification_followers (array of employee IDs)
const notificationFollowers = autoAddData?.bodyshops_by_pk?.notification_followers || [];
let followerEmails = []; let followerEmails = [];
if (notificationFollowers.length > 0) { if (notificationFollowers.length > 0) {
const validFollowers = notificationFollowers.filter((id) => id); // Remove null values const validFollowers = notificationFollowers.filter((id) => id); // Remove null values
if (validFollowers.length > 0) { if (validFollowers.length > 0) {
const employeeData = await gqlClient.request(queries.GET_EMPLOYEE_EMAILS, { const employeeData = await gqlClient.request(GET_EMPLOYEE_EMAILS, {
employeeIds: validFollowers, employeeIds: validFollowers,
shopid: shopId shopid: shopId
}); });
@@ -80,12 +90,11 @@ const autoAddWatchers = async (req) => {
}, []); }, []);
if (isEmpty(usersToAdd)) { if (isEmpty(usersToAdd)) {
logger.log(`No users to auto-add for jobId "${jobId}" (RO: ${roNumber})`, "info", "notifications");
return; return;
} }
// Check existing watchers to avoid duplicates // Check existing watchers to avoid duplicates
const existingWatchersData = await gqlClient.request(queries.GET_JOB_WATCHERS, { jobid: jobId }); const existingWatchersData = await gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId });
const existingWatcherEmails = existingWatchersData?.job_watchers?.map((w) => w.user_email) || []; const existingWatcherEmails = existingWatchersData?.job_watchers?.map((w) => w.user_email) || [];
// Filter out already existing watchers and optionally the user who created the job // Filter out already existing watchers and optionally the user who created the job
@@ -104,23 +113,11 @@ const autoAddWatchers = async (req) => {
})); }));
if (isEmpty(newWatchers)) { if (isEmpty(newWatchers)) {
logger.log(
`No new watchers to add after filtering for jobId "${jobId}" (RO: ${roNumber})`,
"info",
"notifications"
);
return; return;
} }
// Insert new watchers // Insert new watchers
await gqlClient.request(queries.INSERT_JOB_WATCHERS, { watchers: newWatchers }); await gqlClient.request(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) { } catch (error) {
logger.log("Error adding auto-add watchers", "error", "notifications", null, { logger.log("Error adding auto-add watchers", "error", "notifications", null, {
message: error?.message, message: error?.message,