mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00: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
|
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,
|
||||||
|
@ -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,
|
||||||
|
@ -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>© {{ current_year }} Coder. All rights reserved - <a href="{{ base_url }}" style="color: #2563eb; text-decoration: none;">{{ base_url }}</a></p>
|
<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" 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>
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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"`
|
||||||
|
@ -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>;
|
||||||
|
};
|
||||||
|
@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user