diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 89822a72a7855..785ba644e9688 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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, diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index 983d0d56e40d4..fa916c95179af 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -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, diff --git a/coderd/notifications/dispatch/smtp/html.gotmpl b/coderd/notifications/dispatch/smtp/html.gotmpl index ac0527b9742d2..78ac053cc7b4f 100644 --- a/coderd/notifications/dispatch/smtp/html.gotmpl +++ b/coderd/notifications/dispatch/smtp/html.gotmpl @@ -26,6 +26,7 @@

© {{ current_year }} Coder. All rights reserved - {{ base_url }}

Click here to manage your notification settings

+

Stop receiving emails like this

diff --git a/coderd/notifications/enqueuer.go b/coderd/notifications/enqueuer.go index 2915299ef26d5..3a2cdaac687ca 100644 --- a/coderd/notifications/enqueuer.go +++ b/coderd/notifications/enqueuer.go @@ -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, diff --git a/coderd/notifications/render/gotmpl_test.go b/coderd/notifications/render/gotmpl_test.go index ec2ec7ffe6237..25e52cc07f671 100644 --- a/coderd/notifications/render/gotmpl_test.go +++ b/coderd/notifications/render/gotmpl_test.go @@ -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 { diff --git a/coderd/notifications/types/payload.go b/coderd/notifications/types/payload.go index ba666219af654..fbcec19bf76ed 100644 --- a/coderd/notifications/types/payload.go +++ b/coderd/notifications/types/payload.go @@ -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"` diff --git a/site/src/api/queries/notifications.ts b/site/src/api/queries/notifications.ts index f8557637e72f7..c08956b0700de 100644 --- a/site/src/api/queries/notifications.ts +++ b/site/src/api/queries/notifications.ts @@ -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; +}; diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx index 78e3778a24c75..cd37bcbd1fdd2 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -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 = { +const meta = { title: "pages/UserSettingsPage/NotificationsPage", component: NotificationsPage, parameters: { @@ -42,7 +44,7 @@ const meta: Meta = { permissions: { viewDeploymentValues: true }, }, decorators: [withGlobalSnackbar, withAuthProvider, withDashboardProvider], -}; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -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 ; + }, + ], + 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 ; + }, + ], + play: async () => { + await within(document.body).findByText("Error disabling notification"); + }, +}; diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 532d457656c3e..49f01f1f00936 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -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;