mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: turn off notification via email (#14520)
This commit is contained in:
@@ -3499,6 +3499,7 @@ func (q *sqlQuerier) EnqueueNotificationMessage(ctx context.Context, arg Enqueue
|
||||
|
||||
const fetchNewMessageMetadata = `-- name: FetchNewMessageMetadata :one
|
||||
SELECT nt.name AS notification_name,
|
||||
nt.id AS notification_template_id,
|
||||
nt.actions AS actions,
|
||||
nt.method AS custom_method,
|
||||
u.id AS user_id,
|
||||
@@ -3517,13 +3518,14 @@ type FetchNewMessageMetadataParams struct {
|
||||
}
|
||||
|
||||
type FetchNewMessageMetadataRow struct {
|
||||
NotificationName string `db:"notification_name" json:"notification_name"`
|
||||
Actions []byte `db:"actions" json:"actions"`
|
||||
CustomMethod NullNotificationMethod `db:"custom_method" json:"custom_method"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
UserEmail string `db:"user_email" json:"user_email"`
|
||||
UserName string `db:"user_name" json:"user_name"`
|
||||
UserUsername string `db:"user_username" json:"user_username"`
|
||||
NotificationName string `db:"notification_name" json:"notification_name"`
|
||||
NotificationTemplateID uuid.UUID `db:"notification_template_id" json:"notification_template_id"`
|
||||
Actions []byte `db:"actions" json:"actions"`
|
||||
CustomMethod NullNotificationMethod `db:"custom_method" json:"custom_method"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
UserEmail string `db:"user_email" json:"user_email"`
|
||||
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.
|
||||
@@ -3532,6 +3534,7 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe
|
||||
var i FetchNewMessageMetadataRow
|
||||
err := row.Scan(
|
||||
&i.NotificationName,
|
||||
&i.NotificationTemplateID,
|
||||
&i.Actions,
|
||||
&i.CustomMethod,
|
||||
&i.UserID,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- name: FetchNewMessageMetadata :one
|
||||
-- This is used to build up the notification_message's JSON payload.
|
||||
SELECT nt.name AS notification_name,
|
||||
nt.id AS notification_template_id,
|
||||
nt.actions AS actions,
|
||||
nt.method AS custom_method,
|
||||
u.id AS user_id,
|
||||
|
||||
@@ -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;">
|
||||
<p>© {{ current_year }} Coder. All rights reserved - <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?disabled={{ .NotificationTemplateID }}" style="color: #2563eb; text-decoration: none;">Stop receiving emails like this</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -121,9 +121,10 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI
|
||||
// actions which can be taken by the recipient.
|
||||
func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRow, labels map[string]string) (*types.MessagePayload, error) {
|
||||
payload := types.MessagePayload{
|
||||
Version: "1.0",
|
||||
Version: "1.1",
|
||||
|
||||
NotificationName: metadata.NotificationName,
|
||||
NotificationName: metadata.NotificationName,
|
||||
NotificationTemplateID: metadata.NotificationTemplateID.String(),
|
||||
|
||||
UserID: metadata.UserID.String(),
|
||||
UserEmail: metadata.UserEmail,
|
||||
|
||||
@@ -56,6 +56,15 @@ func TestGoTemplate(t *testing.T) {
|
||||
"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 {
|
||||
|
||||
@@ -7,7 +7,8 @@ package types
|
||||
type MessagePayload struct {
|
||||
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"`
|
||||
UserEmail string `json:"user_email"`
|
||||
|
||||
@@ -136,3 +136,22 @@ export const 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>;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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 {
|
||||
notificationDispatchMethodsKey,
|
||||
systemNotificationTemplatesKey,
|
||||
userNotificationPreferencesKey,
|
||||
} from "api/queries/notifications";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import {
|
||||
MockNotificationMethodsResponse,
|
||||
MockNotificationPreferences,
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
} from "testHelpers/storybook";
|
||||
import { NotificationsPage } from "./NotificationsPage";
|
||||
|
||||
const meta: Meta<typeof NotificationsPage> = {
|
||||
const meta = {
|
||||
title: "pages/UserSettingsPage/NotificationsPage",
|
||||
component: NotificationsPage,
|
||||
parameters: {
|
||||
@@ -42,7 +44,7 @@ const meta: Meta<typeof NotificationsPage> = {
|
||||
permissions: { viewDeploymentValues: true },
|
||||
},
|
||||
decorators: [withGlobalSnackbar, withAuthProvider, withDashboardProvider],
|
||||
};
|
||||
} satisfies Meta<typeof NotificationsPage>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NotificationsPage>;
|
||||
@@ -76,3 +78,78 @@ export const NonAdmin: Story = {
|
||||
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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {
|
||||
disableNotification,
|
||||
notificationDispatchMethods,
|
||||
selectTemplatesByGroup,
|
||||
systemNotificationTemplates,
|
||||
@@ -18,7 +19,7 @@ import type {
|
||||
NotificationPreference,
|
||||
NotificationTemplate,
|
||||
} from "api/typesGenerated";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
@@ -28,8 +29,10 @@ import {
|
||||
methodLabels,
|
||||
} from "modules/notifications/utils";
|
||||
import { type FC, Fragment } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQueries, useQueryClient } from "react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { Section } from "../Section";
|
||||
|
||||
@@ -60,6 +63,30 @@ export const NotificationsPage: FC = () => {
|
||||
const updatePreferences = useMutation(
|
||||
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 =
|
||||
disabledPreferences.data && templatesByGroup.data && dispatchMethods.data;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user