feat: allow notification templates to be disabled by default (#16093)

Change as part of https://github.com/coder/coder/pull/16071

It has been decided that we want to be able to have some notification
templates be disabled _by default_
https://github.com/coder/coder/pull/16071#issuecomment-2580757061.

This adds a new column (`enabled_by_default`) to
`notification_templates` that defaults to `TRUE`. It also modifies the
`inhibit_enqueue_if_disabled` function to reject notifications for
templates that have `enabled_by_default = FALSE` with the user not
explicitly enabling it.
This commit is contained in:
Danielle Maywood
2025-01-13 15:01:47 +00:00
committed by GitHub
parent 22236f2988
commit 009069cd47
20 changed files with 231 additions and 62 deletions

3
coderd/apidoc/docs.go generated
View File

@@ -11712,6 +11712,9 @@ const docTemplate = `{
"body_template": {
"type": "string"
},
"enabled_by_default": {
"type": "boolean"
},
"group": {
"type": "string"
},

View File

@@ -10506,6 +10506,9 @@
"body_template": {
"type": "string"
},
"enabled_by_default": {
"type": "boolean"
},
"group": {
"type": "string"
},

View File

@@ -346,13 +346,24 @@ CREATE FUNCTION inhibit_enqueue_if_disabled() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Fail the insertion if the user has disabled this notification.
IF EXISTS (SELECT 1
FROM notification_preferences
WHERE disabled = TRUE
AND user_id = NEW.user_id
AND notification_template_id = NEW.notification_template_id) THEN
RAISE EXCEPTION 'cannot enqueue message: user has disabled this notification';
-- Fail the insertion if one of the following:
-- * the user has disabled this notification.
-- * the notification template is disabled by default and hasn't
-- been explicitly enabled by the user.
IF EXISTS (
SELECT 1 FROM notification_templates
LEFT JOIN notification_preferences
ON notification_preferences.notification_template_id = notification_templates.id
AND notification_preferences.user_id = NEW.user_id
WHERE notification_templates.id = NEW.notification_template_id AND (
-- Case 1: The user has explicitly disabled this template
notification_preferences.disabled = TRUE
OR
-- Case 2: The template is disabled by default AND the user hasn't enabled it
(notification_templates.enabled_by_default = FALSE AND notification_preferences.notification_template_id IS NULL)
)
) THEN
RAISE EXCEPTION 'cannot enqueue message: notification is not enabled';
END IF;
RETURN NEW;
@@ -874,7 +885,8 @@ CREATE TABLE notification_templates (
actions jsonb,
"group" text,
method notification_method,
kind notification_template_kind DEFAULT 'system'::notification_template_kind NOT NULL
kind notification_template_kind DEFAULT 'system'::notification_template_kind NOT NULL,
enabled_by_default boolean DEFAULT true NOT NULL
);
COMMENT ON TABLE notification_templates IS 'Templates from which to create notification messages.';

View File

@@ -0,0 +1,18 @@
ALTER TABLE notification_templates DROP COLUMN enabled_by_default;
CREATE OR REPLACE FUNCTION inhibit_enqueue_if_disabled()
RETURNS TRIGGER AS
$$
BEGIN
-- Fail the insertion if the user has disabled this notification.
IF EXISTS (SELECT 1
FROM notification_preferences
WHERE disabled = TRUE
AND user_id = NEW.user_id
AND notification_template_id = NEW.notification_template_id) THEN
RAISE EXCEPTION 'cannot enqueue message: user has disabled this notification';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,29 @@
ALTER TABLE notification_templates ADD COLUMN enabled_by_default boolean DEFAULT TRUE NOT NULL;
CREATE OR REPLACE FUNCTION inhibit_enqueue_if_disabled()
RETURNS TRIGGER AS
$$
BEGIN
-- Fail the insertion if one of the following:
-- * the user has disabled this notification.
-- * the notification template is disabled by default and hasn't
-- been explicitly enabled by the user.
IF EXISTS (
SELECT 1 FROM notification_templates
LEFT JOIN notification_preferences
ON notification_preferences.notification_template_id = notification_templates.id
AND notification_preferences.user_id = NEW.user_id
WHERE notification_templates.id = NEW.notification_template_id AND (
-- Case 1: The user has explicitly disabled this template
notification_preferences.disabled = TRUE
OR
-- Case 2: The template is disabled by default AND the user hasn't enabled it
(notification_templates.enabled_by_default = FALSE AND notification_preferences.notification_template_id IS NULL)
)
) THEN
RAISE EXCEPTION 'cannot enqueue message: notification is not enabled';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,9 @@
-- Enable 'workspace created' notification by default
UPDATE notification_templates
SET enabled_by_default = TRUE
WHERE id = '281fdf73-c6d6-4cbb-8ff5-888baf8a2fff';
-- Enable 'workspace manually updated' notification by default
UPDATE notification_templates
SET enabled_by_default = TRUE
WHERE id = 'd089fe7b-d5c5-4c0c-aaf5-689859f7d392';

View File

@@ -0,0 +1,9 @@
-- Disable 'workspace created' notification by default
UPDATE notification_templates
SET enabled_by_default = FALSE
WHERE id = '281fdf73-c6d6-4cbb-8ff5-888baf8a2fff';
-- Disable 'workspace manually updated' notification by default
UPDATE notification_templates
SET enabled_by_default = FALSE
WHERE id = 'd089fe7b-d5c5-4c0c-aaf5-689859f7d392';

View File

@@ -2480,8 +2480,9 @@ type NotificationTemplate struct {
Actions []byte `db:"actions" json:"actions"`
Group sql.NullString `db:"group" json:"group"`
// NULL defers to the deployment-level method
Method NullNotificationMethod `db:"method" json:"method"`
Kind NotificationTemplateKind `db:"kind" json:"kind"`
Method NullNotificationMethod `db:"method" json:"method"`
Kind NotificationTemplateKind `db:"kind" json:"kind"`
EnabledByDefault bool `db:"enabled_by_default" json:"enabled_by_default"`
}
// A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.

View File

@@ -4134,7 +4134,7 @@ func (q *sqlQuerier) GetNotificationReportGeneratorLogByTemplate(ctx context.Con
}
const getNotificationTemplateByID = `-- name: GetNotificationTemplateByID :one
SELECT id, name, title_template, body_template, actions, "group", method, kind
SELECT id, name, title_template, body_template, actions, "group", method, kind, enabled_by_default
FROM notification_templates
WHERE id = $1::uuid
`
@@ -4151,12 +4151,13 @@ func (q *sqlQuerier) GetNotificationTemplateByID(ctx context.Context, id uuid.UU
&i.Group,
&i.Method,
&i.Kind,
&i.EnabledByDefault,
)
return i, err
}
const getNotificationTemplatesByKind = `-- name: GetNotificationTemplatesByKind :many
SELECT id, name, title_template, body_template, actions, "group", method, kind
SELECT id, name, title_template, body_template, actions, "group", method, kind, enabled_by_default
FROM notification_templates
WHERE kind = $1::notification_template_kind
ORDER BY name ASC
@@ -4180,6 +4181,7 @@ func (q *sqlQuerier) GetNotificationTemplatesByKind(ctx context.Context, kind No
&i.Group,
&i.Method,
&i.Kind,
&i.EnabledByDefault,
); err != nil {
return nil, err
}
@@ -4233,7 +4235,7 @@ const updateNotificationTemplateMethodByID = `-- name: UpdateNotificationTemplat
UPDATE notification_templates
SET method = $1::notification_method
WHERE id = $2::uuid
RETURNING id, name, title_template, body_template, actions, "group", method, kind
RETURNING id, name, title_template, body_template, actions, "group", method, kind, enabled_by_default
`
type UpdateNotificationTemplateMethodByIDParams struct {
@@ -4253,6 +4255,7 @@ func (q *sqlQuerier) UpdateNotificationTemplateMethodByID(ctx context.Context, a
&i.Group,
&i.Method,
&i.Kind,
&i.EnabledByDefault,
)
return i, err
}

View File

@@ -271,14 +271,15 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R
func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate) {
for _, tmpl := range in {
out = append(out, codersdk.NotificationTemplate{
ID: tmpl.ID,
Name: tmpl.Name,
TitleTemplate: tmpl.TitleTemplate,
BodyTemplate: tmpl.BodyTemplate,
Actions: string(tmpl.Actions),
Group: tmpl.Group.String,
Method: string(tmpl.Method.NotificationMethod),
Kind: string(tmpl.Kind),
ID: tmpl.ID,
Name: tmpl.Name,
TitleTemplate: tmpl.TitleTemplate,
BodyTemplate: tmpl.BodyTemplate,
Actions: string(tmpl.Actions),
Group: tmpl.Group.String,
Method: string(tmpl.Method.NotificationMethod),
Kind: string(tmpl.Kind),
EnabledByDefault: tmpl.EnabledByDefault,
})
}

View File

@@ -20,7 +20,7 @@ import (
)
var (
ErrCannotEnqueueDisabledNotification = xerrors.New("user has disabled this notification")
ErrCannotEnqueueDisabledNotification = xerrors.New("notification is not enabled")
ErrDuplicate = xerrors.New("duplicate notification")
)

View File

@@ -1106,6 +1106,20 @@ func TestNotificationTemplates_Golden(t *testing.T) {
r.Name = tc.payload.UserName
},
)
// With the introduction of notifications that can be disabled
// by default, we want to make sure the user preferences have
// the notification enabled.
_, err := adminClient.UpdateUserNotificationPreferences(
context.Background(),
user.ID,
codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
tc.id.String(): false,
},
})
require.NoError(t, err)
return &db, &api.Logger, &user
}()
@@ -1275,6 +1289,20 @@ func TestNotificationTemplates_Golden(t *testing.T) {
r.Name = tc.payload.UserName
},
)
// With the introduction of notifications that can be disabled
// by default, we want to make sure the user preferences have
// the notification enabled.
_, err := adminClient.UpdateUserNotificationPreferences(
context.Background(),
user.ID,
codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
tc.id.String(): false,
},
})
require.NoError(t, err)
return &db, &api.Logger, &user
}()
@@ -1410,6 +1438,30 @@ func normalizeGoldenWebhook(content []byte) []byte {
return content
}
func TestDisabledByDefaultBeforeEnqueue(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres; it is testing business-logic implemented in the database")
}
// nolint:gocritic // Unit test.
ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong))
store, _ := dbtestutil.NewDB(t)
logger := testutil.Logger(t)
cfg := defaultNotificationsConfig(database.NotificationMethodSmtp)
enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"), quartz.NewReal())
require.NoError(t, err)
user := createSampleUser(t, store)
// We want to try enqueuing a notification on a template that is disabled
// by default. We expect this to fail.
templateID := notifications.TemplateWorkspaceManuallyUpdated
_, err = enq.Enqueue(ctx, user.ID, templateID, map[string]string{}, "test")
require.ErrorIs(t, err, notifications.ErrCannotEnqueueDisabledNotification, "enqueuing did not fail with expected error")
}
// TestDisabledBeforeEnqueue ensures that notifications cannot be enqueued once a user has disabled that notification template
func TestDisabledBeforeEnqueue(t *testing.T) {
t.Parallel()

View File

@@ -17,14 +17,15 @@ type NotificationsSettings struct {
}
type NotificationTemplate struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
TitleTemplate string `json:"title_template"`
BodyTemplate string `json:"body_template"`
Actions string `json:"actions" format:""`
Group string `json:"group"`
Method string `json:"method"`
Kind string `json:"kind"`
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
TitleTemplate string `json:"title_template"`
BodyTemplate string `json:"body_template"`
Actions string `json:"actions" format:""`
Group string `json:"group"`
Method string `json:"method"`
Kind string `json:"kind"`
EnabledByDefault bool `json:"enabled_by_default"`
}
type NotificationMethodsResponse struct {

View File

@@ -19,7 +19,7 @@ We track the following resources:
| GroupSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>auto_create_missing_groups</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>legacy_group_name_mapping</td><td>false</td></tr><tr><td>mapping</td><td>true</td></tr><tr><td>regex_filter</td><td>true</td></tr></tbody></table> |
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| NotificationTemplate<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>actions</td><td>true</td></tr><tr><td>body_template</td><td>true</td></tr><tr><td>group</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>kind</td><td>true</td></tr><tr><td>method</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>title_template</td><td>true</td></tr></tbody></table> |
| NotificationTemplate<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>actions</td><td>true</td></tr><tr><td>body_template</td><td>true</td></tr><tr><td>enabled_by_default</td><td>true</td></tr><tr><td>group</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>kind</td><td>true</td></tr><tr><td>method</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>title_template</td><td>true</td></tr></tbody></table> |
| NotificationsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>notifier_paused</td><td>true</td></tr></tbody></table> |
| OAuth2ProviderApp<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>callback_url</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| OAuth2ProviderAppSecret<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>app_id</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>display_secret</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>secret_prefix</td><td>false</td></tr></tbody></table> |

View File

@@ -146,6 +146,7 @@ curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \
{
"actions": "string",
"body_template": "string",
"enabled_by_default": true,
"group": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"kind": "string",
@@ -166,17 +167,18 @@ curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \
Status Code **200**
| Name | Type | Required | Restrictions | Description |
|--------------------|--------------|----------|--------------|-------------|
| `[array item]` | array | false | | |
| `» actions` | string | false | | |
| `» body_template` | string | false | | |
| group` | string | false | | |
| id` | string(uuid) | false | | |
| kind` | string | false | | |
| method` | string | false | | |
| name` | string | false | | |
| title_template` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|------------------------|--------------|----------|--------------|-------------|
| `[array item]` | array | false | | |
| `» actions` | string | false | | |
| `» body_template` | string | false | | |
| enabled_by_default` | boolean | false | | |
| group` | string | false | | |
| id` | string(uuid) | false | | |
| kind` | string | false | | |
| method` | string | false | | |
| name` | string | false | | |
| `» title_template` | string | false | | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

View File

@@ -3550,6 +3550,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
{
"actions": "string",
"body_template": "string",
"enabled_by_default": true,
"group": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"kind": "string",
@@ -3561,16 +3562,17 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
### Properties
| Name | Type | Required | Restrictions | Description |
|------------------|--------|----------|--------------|-------------|
| `actions` | string | false | | |
| `body_template` | string | false | | |
| `group` | string | false | | |
| `id` | string | false | | |
| `kind` | string | false | | |
| `method` | string | false | | |
| `name` | string | false | | |
| `title_template` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|----------------------|---------|----------|--------------|-------------|
| `actions` | string | false | | |
| `body_template` | string | false | | |
| `enabled_by_default` | boolean | false | | |
| `group` | string | false | | |
| `id` | string | false | | |
| `kind` | string | false | | |
| `method` | string | false | | |
| `name` | string | false | | |
| `title_template` | string | false | | |
## codersdk.NotificationsConfig

View File

@@ -279,14 +279,15 @@ var auditableResourcesTypes = map[any]map[string]Action{
"icon": ActionTrack,
},
&database.NotificationTemplate{}: {
"id": ActionIgnore,
"name": ActionTrack,
"title_template": ActionTrack,
"body_template": ActionTrack,
"actions": ActionTrack,
"group": ActionTrack,
"method": ActionTrack,
"kind": ActionTrack,
"id": ActionIgnore,
"name": ActionTrack,
"title_template": ActionTrack,
"body_template": ActionTrack,
"actions": ActionTrack,
"group": ActionTrack,
"method": ActionTrack,
"kind": ActionTrack,
"enabled_by_default": ActionTrack,
},
&idpsync.OrganizationSyncSettings{}: {
"field": ActionTrack,

View File

@@ -1228,6 +1228,7 @@ export interface NotificationTemplate {
readonly group: string;
readonly method: string;
readonly kind: string;
readonly enabled_by_default: boolean;
}
// From codersdk/deployment.go

View File

@@ -105,7 +105,7 @@ export const NotificationsPage: FC = () => {
<Stack spacing={4}>
{Object.entries(templatesByGroup.data).map(([group, templates]) => {
const allDisabled = templates.some((tpl) => {
return disabledPreferences.data[tpl.id] === true;
return notificationIsDisabled(disabledPreferences.data, tpl);
});
return (
@@ -150,6 +150,11 @@ export const NotificationsPage: FC = () => {
const label = methodLabels[method];
const isLastItem = i === templates.length - 1;
const disabled = notificationIsDisabled(
disabledPreferences.data,
tmpl,
);
return (
<Fragment key={tmpl.id}>
<ListItem>
@@ -157,7 +162,7 @@ export const NotificationsPage: FC = () => {
<Switch
size="small"
id={tmpl.id}
checked={!disabledPreferences.data[tmpl.id]}
checked={!disabled}
onChange={async (_, checked) => {
await updatePreferences.mutateAsync({
template_disabled_map: {
@@ -207,6 +212,16 @@ export const NotificationsPage: FC = () => {
export default NotificationsPage;
function notificationIsDisabled(
disabledPreferences: Record<string, boolean>,
tmpl: NotificationTemplate,
): boolean {
return (
(!tmpl.enabled_by_default && disabledPreferences[tmpl.id] === undefined) ||
!!disabledPreferences[tmpl.id]
);
}
function selectDisabledPreferences(data: NotificationPreference[]) {
return data.reduce(
(acc, pref) => {

View File

@@ -4052,6 +4052,7 @@ export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
group: "Workspace Events",
method: "webhook",
kind: "system",
enabled_by_default: true,
},
{
id: "f517da0b-cdc9-410f-ab89-a86107c420ed",
@@ -4064,6 +4065,7 @@ export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
group: "Workspace Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "f44d9314-ad03-4bc8-95d0-5cad491da6b6",
@@ -4076,6 +4078,7 @@ export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
group: "User Events",
method: "",
kind: "system",
enabled_by_default: true,
},
{
id: "4e19c0ac-94e1-4532-9515-d1801aa283b2",
@@ -4088,6 +4091,7 @@ export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
group: "User Events",
method: "",
kind: "system",
enabled_by_default: true,
},
{
id: "0ea69165-ec14-4314-91f1-69566ac3c5a0",
@@ -4100,6 +4104,7 @@ export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
group: "Workspace Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "c34a0c09-0704-4cac-bd1c-0c0146811c2b",
@@ -4112,6 +4117,7 @@ export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
group: "Workspace Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "51ce2fdf-c9ca-4be1-8d70-628674f9bc42",
@@ -4124,6 +4130,7 @@ export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
group: "Workspace Events",
method: "webhook",
kind: "system",
enabled_by_default: true,
},
];