Skip to content

Commit 3301212

Browse files
authored
feat: turn off notification via email (#14520)
1 parent 5bd19f8 commit 3301212

File tree

9 files changed

+153
-14
lines changed

9 files changed

+153
-14
lines changed

coderd/database/queries.sql.go

+10-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/notifications.sql

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
-- name: FetchNewMessageMetadata :one
22
-- This is used to build up the notification_message's JSON payload.
33
SELECT nt.name AS notification_name,
4+
nt.id AS notification_template_id,
45
nt.actions AS actions,
56
nt.method AS custom_method,
67
u.id AS user_id,

coderd/notifications/dispatch/smtp/html.gotmpl

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<div style="border-top: 1px solid #e2e8f0; color: #475569; font-size: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
2727
<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>
2828
<p><a href="{{ base_url }}/settings/notifications" style="color: #2563eb; text-decoration: none;">Click here to manage your notification settings</a></p>
29+
<p><a href="{{ base_url }}/settings/notifications?disabled={{ .NotificationTemplateID }}" style="color: #2563eb; text-decoration: none;">Stop receiving emails like this</a></p>
2930
</div>
3031
</div>
3132
</body>

coderd/notifications/enqueuer.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,10 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI
121121
// actions which can be taken by the recipient.
122122
func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRow, labels map[string]string) (*types.MessagePayload, error) {
123123
payload := types.MessagePayload{
124-
Version: "1.0",
124+
Version: "1.1",
125125

126-
NotificationName: metadata.NotificationName,
126+
NotificationName: metadata.NotificationName,
127+
NotificationTemplateID: metadata.NotificationTemplateID.String(),
127128

128129
UserID: metadata.UserID.String(),
129130
UserEmail: metadata.UserEmail,

coderd/notifications/render/gotmpl_test.go

+9
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ func TestGoTemplate(t *testing.T) {
5656
"url": "https://mocked-server-address/@johndoe/my-workspace"
5757
}]`,
5858
},
59+
{
60+
name: "render notification template ID",
61+
in: `{{ .NotificationTemplateID }}`,
62+
payload: types.MessagePayload{
63+
NotificationTemplateID: "4e19c0ac-94e1-4532-9515-d1801aa283b2",
64+
},
65+
expectedOutput: "4e19c0ac-94e1-4532-9515-d1801aa283b2",
66+
expectedErr: nil,
67+
},
5968
}
6069

6170
for _, tc := range tests {

coderd/notifications/types/payload.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ package types
77
type MessagePayload struct {
88
Version string `json:"_version"`
99

10-
NotificationName string `json:"notification_name"`
10+
NotificationName string `json:"notification_name"`
11+
NotificationTemplateID string `json:"notification_template_id"`
1112

1213
UserID string `json:"user_id"`
1314
UserEmail string `json:"user_email"`

site/src/api/queries/notifications.ts

+19
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,22 @@ export const updateNotificationTemplateMethod = (
136136
UpdateNotificationTemplateMethod
137137
>;
138138
};
139+
140+
export const disableNotification = (
141+
userId: string,
142+
queryClient: QueryClient,
143+
) => {
144+
return {
145+
mutationFn: async (templateId: string) => {
146+
const result = await API.putUserNotificationPreferences(userId, {
147+
template_disabled_map: {
148+
[templateId]: true,
149+
},
150+
});
151+
return result;
152+
},
153+
onSuccess: (data) => {
154+
queryClient.setQueryData(userNotificationPreferencesKey(userId), data);
155+
},
156+
} satisfies UseMutationOptions<NotificationPreference[], unknown, string>;
157+
};

site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx

+80-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { Meta, StoryObj } from "@storybook/react";
2-
import { spyOn, userEvent, within } from "@storybook/test";
2+
import { expect, spyOn, userEvent, waitFor, within } from "@storybook/test";
33
import { API } from "api/api";
44
import {
55
notificationDispatchMethodsKey,
66
systemNotificationTemplatesKey,
77
userNotificationPreferencesKey,
88
} from "api/queries/notifications";
9+
import { http, HttpResponse } from "msw";
10+
import { reactRouterParameters } from "storybook-addon-remix-react-router";
911
import {
1012
MockNotificationMethodsResponse,
1113
MockNotificationPreferences,
@@ -19,7 +21,7 @@ import {
1921
} from "testHelpers/storybook";
2022
import { NotificationsPage } from "./NotificationsPage";
2123

22-
const meta: Meta<typeof NotificationsPage> = {
24+
const meta = {
2325
title: "pages/UserSettingsPage/NotificationsPage",
2426
component: NotificationsPage,
2527
parameters: {
@@ -42,7 +44,7 @@ const meta: Meta<typeof NotificationsPage> = {
4244
permissions: { viewDeploymentValues: true },
4345
},
4446
decorators: [withGlobalSnackbar, withAuthProvider, withDashboardProvider],
45-
};
47+
} satisfies Meta<typeof NotificationsPage>;
4648

4749
export default meta;
4850
type Story = StoryObj<typeof NotificationsPage>;
@@ -76,3 +78,78 @@ export const NonAdmin: Story = {
7678
permissions: { viewDeploymentValues: false },
7779
},
7880
};
81+
82+
// Ensure the selected notification template is enabled before attempting to
83+
// disable it.
84+
const enabledPreference = MockNotificationPreferences.find(
85+
(pref) => pref.disabled === false,
86+
);
87+
if (!enabledPreference) {
88+
throw new Error(
89+
"No enabled notification preference available to test the disabling action.",
90+
);
91+
}
92+
const templateToDisable = MockNotificationTemplates.find(
93+
(tpl) => tpl.id === enabledPreference.id,
94+
);
95+
if (!templateToDisable) {
96+
throw new Error(" No notification template matches the enabled preference.");
97+
}
98+
99+
export const DisableValidTemplate: Story = {
100+
parameters: {
101+
reactRouter: reactRouterParameters({
102+
location: {
103+
searchParams: { disabled: templateToDisable.id },
104+
},
105+
}),
106+
},
107+
decorators: [
108+
(Story) => {
109+
// Since the action occurs during the initial render, we need to spy on
110+
// the API call before the story is rendered. This is done using a
111+
// decorator to ensure the spy is set up in time.
112+
spyOn(API, "putUserNotificationPreferences").mockResolvedValue(
113+
MockNotificationPreferences.map((pref) => {
114+
if (pref.id === templateToDisable.id) {
115+
return {
116+
...pref,
117+
disabled: true,
118+
};
119+
}
120+
return pref;
121+
}),
122+
);
123+
return <Story />;
124+
},
125+
],
126+
play: async ({ canvasElement }) => {
127+
await within(document.body).findByText("Notification has been disabled");
128+
const switchEl = await within(canvasElement).findByLabelText(
129+
templateToDisable.name,
130+
);
131+
expect(switchEl).not.toBeChecked();
132+
},
133+
};
134+
135+
export const DisableInvalidTemplate: Story = {
136+
parameters: {
137+
reactRouter: reactRouterParameters({
138+
location: {
139+
searchParams: { disabled: "invalid-template-id" },
140+
},
141+
}),
142+
},
143+
decorators: [
144+
(Story) => {
145+
// Since the action occurs during the initial render, we need to spy on
146+
// the API call before the story is rendered. This is done using a
147+
// decorator to ensure the spy is set up in time.
148+
spyOn(API, "putUserNotificationPreferences").mockRejectedValue({});
149+
return <Story />;
150+
},
151+
],
152+
play: async () => {
153+
await within(document.body).findByText("Error disabling notification");
154+
},
155+
};

site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx

+28-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText";
88
import Switch from "@mui/material/Switch";
99
import Tooltip from "@mui/material/Tooltip";
1010
import {
11+
disableNotification,
1112
notificationDispatchMethods,
1213
selectTemplatesByGroup,
1314
systemNotificationTemplates,
@@ -18,7 +19,7 @@ import type {
1819
NotificationPreference,
1920
NotificationTemplate,
2021
} from "api/typesGenerated";
21-
import { displaySuccess } from "components/GlobalSnackbar/utils";
22+
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
2223
import { Loader } from "components/Loader/Loader";
2324
import { Stack } from "components/Stack/Stack";
2425
import { useAuthenticated } from "contexts/auth/RequireAuth";
@@ -28,8 +29,10 @@ import {
2829
methodLabels,
2930
} from "modules/notifications/utils";
3031
import { type FC, Fragment } from "react";
32+
import { useEffect } from "react";
3133
import { Helmet } from "react-helmet-async";
3234
import { useMutation, useQueries, useQueryClient } from "react-query";
35+
import { useSearchParams } from "react-router-dom";
3336
import { pageTitle } from "utils/page";
3437
import { Section } from "../Section";
3538

@@ -60,6 +63,30 @@ export const NotificationsPage: FC = () => {
6063
const updatePreferences = useMutation(
6164
updateUserNotificationPreferences(user.id, queryClient),
6265
);
66+
67+
// Notification emails contain a link to disable a specific notification
68+
// template. This functionality is achieved using the query string parameter
69+
// "disabled".
70+
const disableMutation = useMutation(
71+
disableNotification(user.id, queryClient),
72+
);
73+
const [searchParams] = useSearchParams();
74+
const disabledId = searchParams.get("disabled");
75+
useEffect(() => {
76+
if (!disabledId) {
77+
return;
78+
}
79+
searchParams.delete("disabled");
80+
disableMutation
81+
.mutateAsync(disabledId)
82+
.then(() => {
83+
displaySuccess("Notification has been disabled");
84+
})
85+
.catch(() => {
86+
displayError("Error disabling notification");
87+
});
88+
}, [searchParams.delete, disabledId, disableMutation]);
89+
6390
const ready =
6491
disabledPreferences.data && templatesByGroup.data && dispatchMethods.data;
6592

0 commit comments

Comments
 (0)