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

Lines changed: 10 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/notifications.sql

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 3 additions & 2 deletions
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

Lines changed: 9 additions & 0 deletions
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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 19 additions & 0 deletions
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

Lines changed: 80 additions & 3 deletions
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

Lines changed: 28 additions & 1 deletion
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy