feat: turn off notification via email (#14520)

This commit is contained in:
Joobi S B
2024-09-11 19:40:24 +05:30
committed by GitHub
parent 5bd19f8ba3
commit 3301212972
9 changed files with 153 additions and 14 deletions

View File

@ -3499,6 +3499,7 @@ func (q *sqlQuerier) EnqueueNotificationMessage(ctx context.Context, arg Enqueue
const fetchNewMessageMetadata = `-- name: FetchNewMessageMetadata :one const fetchNewMessageMetadata = `-- name: FetchNewMessageMetadata :one
SELECT nt.name AS notification_name, SELECT nt.name AS notification_name,
nt.id AS notification_template_id,
nt.actions AS actions, nt.actions AS actions,
nt.method AS custom_method, nt.method AS custom_method,
u.id AS user_id, u.id AS user_id,
@ -3517,13 +3518,14 @@ type FetchNewMessageMetadataParams struct {
} }
type FetchNewMessageMetadataRow struct { type FetchNewMessageMetadataRow struct {
NotificationName string `db:"notification_name" json:"notification_name"` NotificationName string `db:"notification_name" json:"notification_name"`
Actions []byte `db:"actions" json:"actions"` NotificationTemplateID uuid.UUID `db:"notification_template_id" json:"notification_template_id"`
CustomMethod NullNotificationMethod `db:"custom_method" json:"custom_method"` Actions []byte `db:"actions" json:"actions"`
UserID uuid.UUID `db:"user_id" json:"user_id"` CustomMethod NullNotificationMethod `db:"custom_method" json:"custom_method"`
UserEmail string `db:"user_email" json:"user_email"` UserID uuid.UUID `db:"user_id" json:"user_id"`
UserName string `db:"user_name" json:"user_name"` UserEmail string `db:"user_email" json:"user_email"`
UserUsername string `db:"user_username" json:"user_username"` UserName string `db:"user_name" json:"user_name"`
UserUsername string `db:"user_username" json:"user_username"`
} }
// This is used to build up the notification_message's JSON payload. // This is used to build up the notification_message's JSON payload.
@ -3532,6 +3534,7 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe
var i FetchNewMessageMetadataRow var i FetchNewMessageMetadataRow
err := row.Scan( err := row.Scan(
&i.NotificationName, &i.NotificationName,
&i.NotificationTemplateID,
&i.Actions, &i.Actions,
&i.CustomMethod, &i.CustomMethod,
&i.UserID, &i.UserID,

View File

@ -1,6 +1,7 @@
-- name: FetchNewMessageMetadata :one -- name: FetchNewMessageMetadata :one
-- This is used to build up the notification_message's JSON payload. -- This is used to build up the notification_message's JSON payload.
SELECT nt.name AS notification_name, SELECT nt.name AS notification_name,
nt.id AS notification_template_id,
nt.actions AS actions, nt.actions AS actions,
nt.method AS custom_method, nt.method AS custom_method,
u.id AS user_id, u.id AS user_id,

View File

@ -26,6 +26,7 @@
<div style="border-top: 1px solid #e2e8f0; color: #475569; font-size: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;"> <div style="border-top: 1px solid #e2e8f0; color: #475569; font-size: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;{{ current_year }}&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a href="{{ base_url }}" style="color: #2563eb; text-decoration: none;">{{ base_url }}</a></p> <p>&copy;&nbsp;{{ current_year }}&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a href="{{ base_url }}" style="color: #2563eb; text-decoration: none;">{{ base_url }}</a></p>
<p><a href="{{ base_url }}/settings/notifications" style="color: #2563eb; text-decoration: none;">Click here to manage your notification settings</a></p> <p><a href="{{ base_url }}/settings/notifications" style="color: #2563eb; text-decoration: none;">Click here to manage your notification settings</a></p>
<p><a href="{{ base_url }}/settings/notifications?disabled={{ .NotificationTemplateID }}" style="color: #2563eb; text-decoration: none;">Stop receiving emails like this</a></p>
</div> </div>
</div> </div>
</body> </body>

View File

@ -121,9 +121,10 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI
// actions which can be taken by the recipient. // actions which can be taken by the recipient.
func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRow, labels map[string]string) (*types.MessagePayload, error) { func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRow, labels map[string]string) (*types.MessagePayload, error) {
payload := types.MessagePayload{ payload := types.MessagePayload{
Version: "1.0", Version: "1.1",
NotificationName: metadata.NotificationName, NotificationName: metadata.NotificationName,
NotificationTemplateID: metadata.NotificationTemplateID.String(),
UserID: metadata.UserID.String(), UserID: metadata.UserID.String(),
UserEmail: metadata.UserEmail, UserEmail: metadata.UserEmail,

View File

@ -56,6 +56,15 @@ func TestGoTemplate(t *testing.T) {
"url": "https://mocked-server-address/@johndoe/my-workspace" "url": "https://mocked-server-address/@johndoe/my-workspace"
}]`, }]`,
}, },
{
name: "render notification template ID",
in: `{{ .NotificationTemplateID }}`,
payload: types.MessagePayload{
NotificationTemplateID: "4e19c0ac-94e1-4532-9515-d1801aa283b2",
},
expectedOutput: "4e19c0ac-94e1-4532-9515-d1801aa283b2",
expectedErr: nil,
},
} }
for _, tc := range tests { for _, tc := range tests {

View File

@ -7,7 +7,8 @@ package types
type MessagePayload struct { type MessagePayload struct {
Version string `json:"_version"` Version string `json:"_version"`
NotificationName string `json:"notification_name"` NotificationName string `json:"notification_name"`
NotificationTemplateID string `json:"notification_template_id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
UserEmail string `json:"user_email"` UserEmail string `json:"user_email"`

View File

@ -136,3 +136,22 @@ export const updateNotificationTemplateMethod = (
UpdateNotificationTemplateMethod UpdateNotificationTemplateMethod
>; >;
}; };
export const disableNotification = (
userId: string,
queryClient: QueryClient,
) => {
return {
mutationFn: async (templateId: string) => {
const result = await API.putUserNotificationPreferences(userId, {
template_disabled_map: {
[templateId]: true,
},
});
return result;
},
onSuccess: (data) => {
queryClient.setQueryData(userNotificationPreferencesKey(userId), data);
},
} satisfies UseMutationOptions<NotificationPreference[], unknown, string>;
};

View File

@ -1,11 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { spyOn, userEvent, within } from "@storybook/test"; import { expect, spyOn, userEvent, waitFor, within } from "@storybook/test";
import { API } from "api/api"; import { API } from "api/api";
import { import {
notificationDispatchMethodsKey, notificationDispatchMethodsKey,
systemNotificationTemplatesKey, systemNotificationTemplatesKey,
userNotificationPreferencesKey, userNotificationPreferencesKey,
} from "api/queries/notifications"; } from "api/queries/notifications";
import { http, HttpResponse } from "msw";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { import {
MockNotificationMethodsResponse, MockNotificationMethodsResponse,
MockNotificationPreferences, MockNotificationPreferences,
@ -19,7 +21,7 @@ import {
} from "testHelpers/storybook"; } from "testHelpers/storybook";
import { NotificationsPage } from "./NotificationsPage"; import { NotificationsPage } from "./NotificationsPage";
const meta: Meta<typeof NotificationsPage> = { const meta = {
title: "pages/UserSettingsPage/NotificationsPage", title: "pages/UserSettingsPage/NotificationsPage",
component: NotificationsPage, component: NotificationsPage,
parameters: { parameters: {
@ -42,7 +44,7 @@ const meta: Meta<typeof NotificationsPage> = {
permissions: { viewDeploymentValues: true }, permissions: { viewDeploymentValues: true },
}, },
decorators: [withGlobalSnackbar, withAuthProvider, withDashboardProvider], decorators: [withGlobalSnackbar, withAuthProvider, withDashboardProvider],
}; } satisfies Meta<typeof NotificationsPage>;
export default meta; export default meta;
type Story = StoryObj<typeof NotificationsPage>; type Story = StoryObj<typeof NotificationsPage>;
@ -76,3 +78,78 @@ export const NonAdmin: Story = {
permissions: { viewDeploymentValues: false }, permissions: { viewDeploymentValues: false },
}, },
}; };
// Ensure the selected notification template is enabled before attempting to
// disable it.
const enabledPreference = MockNotificationPreferences.find(
(pref) => pref.disabled === false,
);
if (!enabledPreference) {
throw new Error(
"No enabled notification preference available to test the disabling action.",
);
}
const templateToDisable = MockNotificationTemplates.find(
(tpl) => tpl.id === enabledPreference.id,
);
if (!templateToDisable) {
throw new Error(" No notification template matches the enabled preference.");
}
export const DisableValidTemplate: Story = {
parameters: {
reactRouter: reactRouterParameters({
location: {
searchParams: { disabled: templateToDisable.id },
},
}),
},
decorators: [
(Story) => {
// Since the action occurs during the initial render, we need to spy on
// the API call before the story is rendered. This is done using a
// decorator to ensure the spy is set up in time.
spyOn(API, "putUserNotificationPreferences").mockResolvedValue(
MockNotificationPreferences.map((pref) => {
if (pref.id === templateToDisable.id) {
return {
...pref,
disabled: true,
};
}
return pref;
}),
);
return <Story />;
},
],
play: async ({ canvasElement }) => {
await within(document.body).findByText("Notification has been disabled");
const switchEl = await within(canvasElement).findByLabelText(
templateToDisable.name,
);
expect(switchEl).not.toBeChecked();
},
};
export const DisableInvalidTemplate: Story = {
parameters: {
reactRouter: reactRouterParameters({
location: {
searchParams: { disabled: "invalid-template-id" },
},
}),
},
decorators: [
(Story) => {
// Since the action occurs during the initial render, we need to spy on
// the API call before the story is rendered. This is done using a
// decorator to ensure the spy is set up in time.
spyOn(API, "putUserNotificationPreferences").mockRejectedValue({});
return <Story />;
},
],
play: async () => {
await within(document.body).findByText("Error disabling notification");
},
};

View File

@ -8,6 +8,7 @@ import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText";
import Switch from "@mui/material/Switch"; import Switch from "@mui/material/Switch";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import { import {
disableNotification,
notificationDispatchMethods, notificationDispatchMethods,
selectTemplatesByGroup, selectTemplatesByGroup,
systemNotificationTemplates, systemNotificationTemplates,
@ -18,7 +19,7 @@ import type {
NotificationPreference, NotificationPreference,
NotificationTemplate, NotificationTemplate,
} from "api/typesGenerated"; } from "api/typesGenerated";
import { displaySuccess } from "components/GlobalSnackbar/utils"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useAuthenticated } from "contexts/auth/RequireAuth";
@ -28,8 +29,10 @@ import {
methodLabels, methodLabels,
} from "modules/notifications/utils"; } from "modules/notifications/utils";
import { type FC, Fragment } from "react"; import { type FC, Fragment } from "react";
import { useEffect } from "react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useMutation, useQueries, useQueryClient } from "react-query"; import { useMutation, useQueries, useQueryClient } from "react-query";
import { useSearchParams } from "react-router-dom";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import { Section } from "../Section"; import { Section } from "../Section";
@ -60,6 +63,30 @@ export const NotificationsPage: FC = () => {
const updatePreferences = useMutation( const updatePreferences = useMutation(
updateUserNotificationPreferences(user.id, queryClient), updateUserNotificationPreferences(user.id, queryClient),
); );
// Notification emails contain a link to disable a specific notification
// template. This functionality is achieved using the query string parameter
// "disabled".
const disableMutation = useMutation(
disableNotification(user.id, queryClient),
);
const [searchParams] = useSearchParams();
const disabledId = searchParams.get("disabled");
useEffect(() => {
if (!disabledId) {
return;
}
searchParams.delete("disabled");
disableMutation
.mutateAsync(disabledId)
.then(() => {
displaySuccess("Notification has been disabled");
})
.catch(() => {
displayError("Error disabling notification");
});
}, [searchParams.delete, disabledId, disableMutation]);
const ready = const ready =
disabledPreferences.data && templatesByGroup.data && dispatchMethods.data; disabledPreferences.data && templatesByGroup.data && dispatchMethods.data;