diff --git a/cli/notifications_test.go b/cli/notifications_test.go
index 9ea4d7072e4c3..9d7ff8a37abc3 100644
--- a/cli/notifications_test.go
+++ b/cli/notifications_test.go
@@ -16,6 +16,16 @@ import (
"github.com/coder/coder/v2/testutil"
)
+func createOpts(t *testing.T) *coderdtest.Options {
+ t.Helper()
+
+ dt := coderdtest.DeploymentValues(t)
+ dt.Experiments = []string{string(codersdk.ExperimentNotifications)}
+ return &coderdtest.Options{
+ DeploymentValues: dt,
+ }
+}
+
func TestNotifications(t *testing.T) {
t.Parallel()
@@ -42,7 +52,7 @@ func TestNotifications(t *testing.T) {
t.Parallel()
// given
- ownerClient, db := coderdtest.NewWithDatabase(t, nil)
+ ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t))
_ = coderdtest.CreateFirstUser(t, ownerClient)
// when
@@ -72,7 +82,7 @@ func TestPauseNotifications_RegularUser(t *testing.T) {
t.Parallel()
// given
- ownerClient, db := coderdtest.NewWithDatabase(t, nil)
+ ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t))
owner := coderdtest.CreateFirstUser(t, ownerClient)
anotherClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
@@ -87,7 +97,7 @@ func TestPauseNotifications_RegularUser(t *testing.T) {
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
- assert.Contains(t, sdkError.Message, "Insufficient permissions to update notifications settings.")
+ assert.Contains(t, sdkError.Message, "Forbidden.")
// then
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 981be686df469..962fccae0a4ea 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -1547,6 +1547,34 @@ const docTemplate = `{
}
}
},
+ "/notifications/dispatch-methods": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Notifications"
+ ],
+ "summary": "Get notification dispatch methods",
+ "operationId": "get-notification-dispatch-methods",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.NotificationMethodsResponse"
+ }
+ }
+ }
+ }
+ }
+ },
"/notifications/settings": {
"get": {
"security": [
@@ -1558,7 +1586,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "General"
+ "Notifications"
],
"summary": "Get notifications settings",
"operationId": "get-notifications-settings",
@@ -1584,7 +1612,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "General"
+ "Notifications"
],
"summary": "Update notifications settings",
"operationId": "update-notifications-settings",
@@ -1612,6 +1640,68 @@ const docTemplate = `{
}
}
},
+ "/notifications/templates/system": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Notifications"
+ ],
+ "summary": "Get system notification templates",
+ "operationId": "get-system-notification-templates",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.NotificationTemplate"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/notifications/templates/{notification_template}/method": {
+ "put": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Enterprise"
+ ],
+ "summary": "Update notification template dispatch method",
+ "operationId": "update-notification-template-dispatch-method",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Notification template UUID",
+ "name": "notification_template",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success"
+ },
+ "304": {
+ "description": "Not modified"
+ }
+ }
+ }
+ },
"/oauth2-provider/apps": {
"get": {
"security": [
@@ -5354,6 +5444,90 @@ const docTemplate = `{
}
}
},
+ "/users/{user}/notifications/preferences": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Notifications"
+ ],
+ "summary": "Get user notification preferences",
+ "operationId": "get-user-notification-preferences",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID, name, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.NotificationPreference"
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Notifications"
+ ],
+ "summary": "Update user notification preferences",
+ "operationId": "update-user-notification-preferences",
+ "parameters": [
+ {
+ "description": "Preferences",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.UpdateUserNotificationPreferences"
+ }
+ },
+ {
+ "type": "string",
+ "description": "User ID, name, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.NotificationPreference"
+ }
+ }
+ }
+ }
+ }
+ },
"/users/{user}/organizations": {
"get": {
"security": [
@@ -10202,6 +10376,66 @@ const docTemplate = `{
}
}
},
+ "codersdk.NotificationMethodsResponse": {
+ "type": "object",
+ "properties": {
+ "available": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "default": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.NotificationPreference": {
+ "type": "object",
+ "properties": {
+ "disabled": {
+ "type": "boolean"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "codersdk.NotificationTemplate": {
+ "type": "object",
+ "properties": {
+ "actions": {
+ "type": "string"
+ },
+ "body_template": {
+ "type": "string"
+ },
+ "group": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "method": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "title_template": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.NotificationsConfig": {
"type": "object",
"properties": {
@@ -11217,6 +11451,8 @@ const docTemplate = `{
"file",
"group",
"license",
+ "notification_preference",
+ "notification_template",
"oauth2_app",
"oauth2_app_code_token",
"oauth2_app_secret",
@@ -11245,6 +11481,8 @@ const docTemplate = `{
"ResourceFile",
"ResourceGroup",
"ResourceLicense",
+ "ResourceNotificationPreference",
+ "ResourceNotificationTemplate",
"ResourceOauth2App",
"ResourceOauth2AppCodeToken",
"ResourceOauth2AppSecret",
@@ -12513,6 +12751,17 @@ const docTemplate = `{
}
}
},
+ "codersdk.UpdateUserNotificationPreferences": {
+ "type": "object",
+ "properties": {
+ "template_disabled_map": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
"codersdk.UpdateUserPasswordRequest": {
"type": "object",
"required": [
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 14efc71711687..35b8b82a21888 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -1344,6 +1344,30 @@
}
}
},
+ "/notifications/dispatch-methods": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": ["application/json"],
+ "tags": ["Notifications"],
+ "summary": "Get notification dispatch methods",
+ "operationId": "get-notification-dispatch-methods",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.NotificationMethodsResponse"
+ }
+ }
+ }
+ }
+ }
+ },
"/notifications/settings": {
"get": {
"security": [
@@ -1352,7 +1376,7 @@
}
],
"produces": ["application/json"],
- "tags": ["General"],
+ "tags": ["Notifications"],
"summary": "Get notifications settings",
"operationId": "get-notifications-settings",
"responses": {
@@ -1372,7 +1396,7 @@
],
"consumes": ["application/json"],
"produces": ["application/json"],
- "tags": ["General"],
+ "tags": ["Notifications"],
"summary": "Update notifications settings",
"operationId": "update-notifications-settings",
"parameters": [
@@ -1399,6 +1423,60 @@
}
}
},
+ "/notifications/templates/system": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": ["application/json"],
+ "tags": ["Notifications"],
+ "summary": "Get system notification templates",
+ "operationId": "get-system-notification-templates",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.NotificationTemplate"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/notifications/templates/{notification_template}/method": {
+ "put": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": ["application/json"],
+ "tags": ["Enterprise"],
+ "summary": "Update notification template dispatch method",
+ "operationId": "update-notification-template-dispatch-method",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Notification template UUID",
+ "name": "notification_template",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success"
+ },
+ "304": {
+ "description": "Not modified"
+ }
+ }
+ }
+ },
"/oauth2-provider/apps": {
"get": {
"security": [
@@ -4726,6 +4804,80 @@
}
}
},
+ "/users/{user}/notifications/preferences": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": ["application/json"],
+ "tags": ["Notifications"],
+ "summary": "Get user notification preferences",
+ "operationId": "get-user-notification-preferences",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID, name, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.NotificationPreference"
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "consumes": ["application/json"],
+ "produces": ["application/json"],
+ "tags": ["Notifications"],
+ "summary": "Update user notification preferences",
+ "operationId": "update-user-notification-preferences",
+ "parameters": [
+ {
+ "description": "Preferences",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.UpdateUserNotificationPreferences"
+ }
+ },
+ {
+ "type": "string",
+ "description": "User ID, name, or me",
+ "name": "user",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.NotificationPreference"
+ }
+ }
+ }
+ }
+ }
+ },
"/users/{user}/organizations": {
"get": {
"security": [
@@ -9143,6 +9295,66 @@
}
}
},
+ "codersdk.NotificationMethodsResponse": {
+ "type": "object",
+ "properties": {
+ "available": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "default": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.NotificationPreference": {
+ "type": "object",
+ "properties": {
+ "disabled": {
+ "type": "boolean"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "codersdk.NotificationTemplate": {
+ "type": "object",
+ "properties": {
+ "actions": {
+ "type": "string"
+ },
+ "body_template": {
+ "type": "string"
+ },
+ "group": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "method": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "title_template": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.NotificationsConfig": {
"type": "object",
"properties": {
@@ -10119,6 +10331,8 @@
"file",
"group",
"license",
+ "notification_preference",
+ "notification_template",
"oauth2_app",
"oauth2_app_code_token",
"oauth2_app_secret",
@@ -10147,6 +10361,8 @@
"ResourceFile",
"ResourceGroup",
"ResourceLicense",
+ "ResourceNotificationPreference",
+ "ResourceNotificationTemplate",
"ResourceOauth2App",
"ResourceOauth2AppCodeToken",
"ResourceOauth2AppSecret",
@@ -11362,6 +11578,17 @@
}
}
},
+ "codersdk.UpdateUserNotificationPreferences": {
+ "type": "object",
+ "properties": {
+ "template_disabled_map": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
"codersdk.UpdateUserPasswordRequest": {
"type": "object",
"required": ["password"],
diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go
index 129b904c75b03..04943c760a55e 100644
--- a/coderd/audit/diff.go
+++ b/coderd/audit/diff.go
@@ -25,7 +25,8 @@ type Auditable interface {
database.OAuth2ProviderAppSecret |
database.CustomRole |
database.AuditableOrganizationMember |
- database.Organization
+ database.Organization |
+ database.NotificationTemplate
}
// Map is a map of changed fields in an audited resource. It maps field names to
diff --git a/coderd/audit/request.go b/coderd/audit/request.go
index 6c862c6e11103..adaf3ce1f573c 100644
--- a/coderd/audit/request.go
+++ b/coderd/audit/request.go
@@ -16,6 +16,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog"
+
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpmw"
@@ -117,6 +118,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return typed.Username
case database.Organization:
return typed.Name
+ case database.NotificationTemplate:
+ return typed.Name
default:
panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
}
@@ -163,6 +166,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.UserID
case database.Organization:
return typed.ID
+ case database.NotificationTemplate:
+ return typed.ID
default:
panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt))
}
@@ -206,6 +211,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeOrganizationMember
case database.Organization:
return database.ResourceTypeOrganization
+ case database.NotificationTemplate:
+ return database.ResourceTypeNotificationTemplate
default:
panic(fmt.Sprintf("unknown resource %T for ResourceType", typed))
}
@@ -251,6 +258,8 @@ func ResourceRequiresOrgID[T Auditable]() bool {
return true
case database.Organization:
return true
+ case database.NotificationTemplate:
+ return false
default:
panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt))
}
diff --git a/coderd/coderd.go b/coderd/coderd.go
index 6f8a59ad6efc6..7fbfe7d477f06 100644
--- a/coderd/coderd.go
+++ b/coderd/coderd.go
@@ -1050,6 +1050,12 @@ func New(options *Options) *API {
})
r.Get("/gitsshkey", api.gitSSHKey)
r.Put("/gitsshkey", api.regenerateGitSSHKey)
+ r.Route("/notifications", func(r chi.Router) {
+ r.Route("/preferences", func(r chi.Router) {
+ r.Get("/", api.userNotificationPreferences)
+ r.Put("/", api.putUserNotificationPreferences)
+ })
+ })
})
})
})
@@ -1243,9 +1249,16 @@ func New(options *Options) *API {
})
})
r.Route("/notifications", func(r chi.Router) {
- r.Use(apiKeyMiddleware)
+ r.Use(
+ apiKeyMiddleware,
+ httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentNotifications),
+ )
r.Get("/settings", api.notificationsSettings)
r.Put("/settings", api.putNotificationsSettings)
+ r.Route("/templates", func(r chi.Router) {
+ r.Get("/system", api.systemNotificationTemplates)
+ })
+ r.Get("/dispatch-methods", api.notificationDispatchMethods)
})
})
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 941ab4caccfac..2f3567455fed8 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -1474,6 +1474,23 @@ func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg datab
return q.db.GetNotificationMessagesByStatus(ctx, arg)
}
+func (q *querier) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (database.NotificationTemplate, error) {
+ if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationTemplate); err != nil {
+ return database.NotificationTemplate{}, err
+ }
+ return q.db.GetNotificationTemplateByID(ctx, id)
+}
+
+func (q *querier) GetNotificationTemplatesByKind(ctx context.Context, kind database.NotificationTemplateKind) ([]database.NotificationTemplate, error) {
+ // TODO: restrict 'system' kind to admins only?
+ // All notification templates share the same rbac.Object, so there is no need
+ // to authorize them individually. If this passes, all notification templates can be read.
+ if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationTemplate); err != nil {
+ return nil, err
+ }
+ return q.db.GetNotificationTemplatesByKind(ctx, kind)
+}
+
func (q *querier) GetNotificationsSettings(ctx context.Context) (string, error) {
// No authz checks
return q.db.GetNotificationsSettings(ctx)
@@ -2085,6 +2102,13 @@ func (q *querier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([
return q.db.GetUserLinksByUserID(ctx, userID)
}
+func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]database.NotificationPreference, error) {
+ if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationPreference.WithOwner(userID.String())); err != nil {
+ return nil, err
+ }
+ return q.db.GetUserNotificationPreferences(ctx, userID)
+}
+
func (q *querier) GetUserWorkspaceBuildParameters(ctx context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) {
u, err := q.db.GetUserByID(ctx, params.OwnerID)
if err != nil {
@@ -3011,6 +3035,13 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb
return q.db.UpdateMemberRoles(ctx, arg)
}
+func (q *querier) UpdateNotificationTemplateMethodByID(ctx context.Context, arg database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) {
+ if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceNotificationTemplate); err != nil {
+ return database.NotificationTemplate{}, err
+ }
+ return q.db.UpdateNotificationTemplateMethodByID(ctx, arg)
+}
+
func (q *querier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceOauth2App); err != nil {
return database.OAuth2ProviderApp{}, err
@@ -3326,6 +3357,13 @@ func (q *querier) UpdateUserLoginType(ctx context.Context, arg database.UpdateUs
return q.db.UpdateUserLoginType(ctx, arg)
}
+func (q *querier) UpdateUserNotificationPreferences(ctx context.Context, arg database.UpdateUserNotificationPreferencesParams) (int64, error) {
+ if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceNotificationPreference.WithOwner(arg.UserID.String())); err != nil {
+ return -1, err
+ }
+ return q.db.UpdateUserNotificationPreferences(ctx, arg)
+}
+
func (q *querier) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) {
u, err := q.db.GetUserByID(ctx, arg.ID)
if err != nil {
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index 627558dbe1f73..95d1bbcdb7f18 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -16,6 +16,7 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database/db2sdk"
+ "github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
@@ -2561,6 +2562,10 @@ func (s *MethodTestSuite) TestSystemFunctions() {
AgentID: uuid.New(),
}).Asserts(tpl, policy.ActionCreate)
}))
+}
+
+func (s *MethodTestSuite) TestNotifications() {
+ // System functions
s.Run("AcquireNotificationMessages", s.Subtest(func(db database.Store, check *expects) {
// TODO: update this test once we have a specific role for notifications
check.Args(database.AcquireNotificationMessagesParams{}).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
@@ -2596,6 +2601,40 @@ func (s *MethodTestSuite) TestSystemFunctions() {
Limit: 10,
}).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
+
+ // Notification templates
+ s.Run("GetNotificationTemplateByID", s.Subtest(func(db database.Store, check *expects) {
+ user := dbgen.User(s.T(), db, database.User{})
+ check.Args(user.ID).Asserts(rbac.ResourceNotificationTemplate, policy.ActionRead).
+ Errors(dbmem.ErrUnimplemented)
+ }))
+ s.Run("GetNotificationTemplatesByKind", s.Subtest(func(db database.Store, check *expects) {
+ check.Args(database.NotificationTemplateKindSystem).
+ Asserts(rbac.ResourceNotificationTemplate, policy.ActionRead).
+ Errors(dbmem.ErrUnimplemented)
+ }))
+ s.Run("UpdateNotificationTemplateMethodByID", s.Subtest(func(db database.Store, check *expects) {
+ check.Args(database.UpdateNotificationTemplateMethodByIDParams{
+ Method: database.NullNotificationMethod{NotificationMethod: database.NotificationMethodWebhook, Valid: true},
+ ID: notifications.TemplateWorkspaceDormant,
+ }).Asserts(rbac.ResourceNotificationTemplate, policy.ActionUpdate).
+ Errors(dbmem.ErrUnimplemented)
+ }))
+
+ // Notification preferences
+ s.Run("GetUserNotificationPreferences", s.Subtest(func(db database.Store, check *expects) {
+ user := dbgen.User(s.T(), db, database.User{})
+ check.Args(user.ID).
+ Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionRead)
+ }))
+ s.Run("UpdateUserNotificationPreferences", s.Subtest(func(db database.Store, check *expects) {
+ user := dbgen.User(s.T(), db, database.User{})
+ check.Args(database.UpdateUserNotificationPreferencesParams{
+ UserID: user.ID,
+ NotificationTemplateIds: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated, notifications.TemplateWorkspaceDeleted},
+ Disableds: []bool{true, false},
+ }).Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionUpdate)
+ }))
}
func (s *MethodTestSuite) TestOAuth2ProviderApps() {
diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go
index 09c0585964795..5768379535668 100644
--- a/coderd/database/dbmem/dbmem.go
+++ b/coderd/database/dbmem/dbmem.go
@@ -65,6 +65,7 @@ func New() database.Store {
files: make([]database.File, 0),
gitSSHKey: make([]database.GitSSHKey, 0),
notificationMessages: make([]database.NotificationMessage, 0),
+ notificationPreferences: make([]database.NotificationPreference, 0),
parameterSchemas: make([]database.ParameterSchema, 0),
provisionerDaemons: make([]database.ProvisionerDaemon, 0),
workspaceAgents: make([]database.WorkspaceAgent, 0),
@@ -160,6 +161,7 @@ type data struct {
jfrogXRayScans []database.JfrogXrayScan
licenses []database.License
notificationMessages []database.NotificationMessage
+ notificationPreferences []database.NotificationPreference
oauth2ProviderApps []database.OAuth2ProviderApp
oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret
oauth2ProviderAppCodes []database.OAuth2ProviderAppCode
@@ -2708,6 +2710,18 @@ func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg dat
return out, nil
}
+func (*FakeQuerier) GetNotificationTemplateByID(_ context.Context, _ uuid.UUID) (database.NotificationTemplate, error) {
+ // Not implementing this function because it relies on state in the database which is created with migrations.
+ // We could consider using code-generation to align the database state and dbmem, but it's not worth it right now.
+ return database.NotificationTemplate{}, ErrUnimplemented
+}
+
+func (*FakeQuerier) GetNotificationTemplatesByKind(_ context.Context, _ database.NotificationTemplateKind) ([]database.NotificationTemplate, error) {
+ // Not implementing this function because it relies on state in the database which is created with migrations.
+ // We could consider using code-generation to align the database state and dbmem, but it's not worth it right now.
+ return nil, ErrUnimplemented
+}
+
func (q *FakeQuerier) GetNotificationsSettings(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -4853,6 +4867,22 @@ func (q *FakeQuerier) GetUserLinksByUserID(_ context.Context, userID uuid.UUID)
return uls, nil
}
+func (q *FakeQuerier) GetUserNotificationPreferences(_ context.Context, userID uuid.UUID) ([]database.NotificationPreference, error) {
+ q.mutex.RLock()
+ defer q.mutex.RUnlock()
+
+ out := make([]database.NotificationPreference, 0, len(q.notificationPreferences))
+ for _, np := range q.notificationPreferences {
+ if np.UserID != userID {
+ continue
+ }
+
+ out = append(out, np)
+ }
+
+ return out, nil
+}
+
func (q *FakeQuerier) GetUserWorkspaceBuildParameters(_ context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -7520,6 +7550,12 @@ func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMe
return database.OrganizationMember{}, sql.ErrNoRows
}
+func (*FakeQuerier) UpdateNotificationTemplateMethodByID(_ context.Context, _ database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) {
+ // Not implementing this function because it relies on state in the database which is created with migrations.
+ // We could consider using code-generation to align the database state and dbmem, but it's not worth it right now.
+ return database.NotificationTemplate{}, ErrUnimplemented
+}
+
func (q *FakeQuerier) UpdateOAuth2ProviderAppByID(_ context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
err := validateDatabaseType(arg)
if err != nil {
@@ -8114,6 +8150,57 @@ func (q *FakeQuerier) UpdateUserLoginType(_ context.Context, arg database.Update
return database.User{}, sql.ErrNoRows
}
+func (q *FakeQuerier) UpdateUserNotificationPreferences(_ context.Context, arg database.UpdateUserNotificationPreferencesParams) (int64, error) {
+ err := validateDatabaseType(arg)
+ if err != nil {
+ return 0, err
+ }
+
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ var upserted int64
+ for i := range arg.NotificationTemplateIds {
+ var (
+ found bool
+ templateID = arg.NotificationTemplateIds[i]
+ disabled = arg.Disableds[i]
+ )
+
+ for j, np := range q.notificationPreferences {
+ if np.UserID != arg.UserID {
+ continue
+ }
+
+ if np.NotificationTemplateID != templateID {
+ continue
+ }
+
+ np.Disabled = disabled
+ np.UpdatedAt = dbtime.Now()
+ q.notificationPreferences[j] = np
+
+ upserted++
+ found = true
+ break
+ }
+
+ if !found {
+ np := database.NotificationPreference{
+ Disabled: disabled,
+ UserID: arg.UserID,
+ NotificationTemplateID: templateID,
+ CreatedAt: dbtime.Now(),
+ UpdatedAt: dbtime.Now(),
+ }
+ q.notificationPreferences = append(q.notificationPreferences, np)
+ upserted++
+ }
+ }
+
+ return upserted, nil
+}
+
func (q *FakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUserProfileParams) (database.User, error) {
if err := validateDatabaseType(arg); err != nil {
return database.User{}, err
diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go
index 1a13ff7f0b5a9..7b6cdb147dcf9 100644
--- a/coderd/database/dbmetrics/dbmetrics.go
+++ b/coderd/database/dbmetrics/dbmetrics.go
@@ -746,6 +746,20 @@ func (m metricsStore) GetNotificationMessagesByStatus(ctx context.Context, arg d
return r0, r1
}
+func (m metricsStore) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (database.NotificationTemplate, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetNotificationTemplateByID(ctx, id)
+ m.queryLatencies.WithLabelValues("GetNotificationTemplateByID").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
+func (m metricsStore) GetNotificationTemplatesByKind(ctx context.Context, kind database.NotificationTemplateKind) ([]database.NotificationTemplate, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetNotificationTemplatesByKind(ctx, kind)
+ m.queryLatencies.WithLabelValues("GetNotificationTemplatesByKind").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
func (m metricsStore) GetNotificationsSettings(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetNotificationsSettings(ctx)
@@ -1222,6 +1236,13 @@ func (m metricsStore) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID
return r0, r1
}
+func (m metricsStore) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]database.NotificationPreference, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetUserNotificationPreferences(ctx, userID)
+ m.queryLatencies.WithLabelValues("GetUserNotificationPreferences").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
func (m metricsStore) GetUserWorkspaceBuildParameters(ctx context.Context, ownerID database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) {
start := time.Now()
r0, r1 := m.s.GetUserWorkspaceBuildParameters(ctx, ownerID)
@@ -1957,6 +1978,13 @@ func (m metricsStore) UpdateMemberRoles(ctx context.Context, arg database.Update
return member, err
}
+func (m metricsStore) UpdateNotificationTemplateMethodByID(ctx context.Context, arg database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) {
+ start := time.Now()
+ r0, r1 := m.s.UpdateNotificationTemplateMethodByID(ctx, arg)
+ m.queryLatencies.WithLabelValues("UpdateNotificationTemplateMethodByID").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
func (m metricsStore) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
start := time.Now()
r0, r1 := m.s.UpdateOAuth2ProviderAppByID(ctx, arg)
@@ -2139,6 +2167,13 @@ func (m metricsStore) UpdateUserLoginType(ctx context.Context, arg database.Upda
return r0, r1
}
+func (m metricsStore) UpdateUserNotificationPreferences(ctx context.Context, arg database.UpdateUserNotificationPreferencesParams) (int64, error) {
+ start := time.Now()
+ r0, r1 := m.s.UpdateUserNotificationPreferences(ctx, arg)
+ m.queryLatencies.WithLabelValues("UpdateUserNotificationPreferences").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
func (m metricsStore) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) {
start := time.Now()
user, err := m.s.UpdateUserProfile(ctx, arg)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index b4aa6043510f1..bda8186a26a4f 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -1495,6 +1495,36 @@ func (mr *MockStoreMockRecorder) GetNotificationMessagesByStatus(arg0, arg1 any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationMessagesByStatus", reflect.TypeOf((*MockStore)(nil).GetNotificationMessagesByStatus), arg0, arg1)
}
+// GetNotificationTemplateByID mocks base method.
+func (m *MockStore) GetNotificationTemplateByID(arg0 context.Context, arg1 uuid.UUID) (database.NotificationTemplate, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetNotificationTemplateByID", arg0, arg1)
+ ret0, _ := ret[0].(database.NotificationTemplate)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetNotificationTemplateByID indicates an expected call of GetNotificationTemplateByID.
+func (mr *MockStoreMockRecorder) GetNotificationTemplateByID(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationTemplateByID", reflect.TypeOf((*MockStore)(nil).GetNotificationTemplateByID), arg0, arg1)
+}
+
+// GetNotificationTemplatesByKind mocks base method.
+func (m *MockStore) GetNotificationTemplatesByKind(arg0 context.Context, arg1 database.NotificationTemplateKind) ([]database.NotificationTemplate, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetNotificationTemplatesByKind", arg0, arg1)
+ ret0, _ := ret[0].([]database.NotificationTemplate)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetNotificationTemplatesByKind indicates an expected call of GetNotificationTemplatesByKind.
+func (mr *MockStoreMockRecorder) GetNotificationTemplatesByKind(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationTemplatesByKind", reflect.TypeOf((*MockStore)(nil).GetNotificationTemplatesByKind), arg0, arg1)
+}
+
// GetNotificationsSettings mocks base method.
func (m *MockStore) GetNotificationsSettings(arg0 context.Context) (string, error) {
m.ctrl.T.Helper()
@@ -2545,6 +2575,21 @@ func (mr *MockStoreMockRecorder) GetUserLinksByUserID(arg0, arg1 any) *gomock.Ca
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLinksByUserID", reflect.TypeOf((*MockStore)(nil).GetUserLinksByUserID), arg0, arg1)
}
+// GetUserNotificationPreferences mocks base method.
+func (m *MockStore) GetUserNotificationPreferences(arg0 context.Context, arg1 uuid.UUID) ([]database.NotificationPreference, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetUserNotificationPreferences", arg0, arg1)
+ ret0, _ := ret[0].([]database.NotificationPreference)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetUserNotificationPreferences indicates an expected call of GetUserNotificationPreferences.
+func (mr *MockStoreMockRecorder) GetUserNotificationPreferences(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).GetUserNotificationPreferences), arg0, arg1)
+}
+
// GetUserWorkspaceBuildParameters mocks base method.
func (m *MockStore) GetUserWorkspaceBuildParameters(arg0 context.Context, arg1 database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) {
m.ctrl.T.Helper()
@@ -4131,6 +4176,21 @@ func (mr *MockStoreMockRecorder) UpdateMemberRoles(arg0, arg1 any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMemberRoles", reflect.TypeOf((*MockStore)(nil).UpdateMemberRoles), arg0, arg1)
}
+// UpdateNotificationTemplateMethodByID mocks base method.
+func (m *MockStore) UpdateNotificationTemplateMethodByID(arg0 context.Context, arg1 database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateNotificationTemplateMethodByID", arg0, arg1)
+ ret0, _ := ret[0].(database.NotificationTemplate)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// UpdateNotificationTemplateMethodByID indicates an expected call of UpdateNotificationTemplateMethodByID.
+func (mr *MockStoreMockRecorder) UpdateNotificationTemplateMethodByID(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateNotificationTemplateMethodByID", reflect.TypeOf((*MockStore)(nil).UpdateNotificationTemplateMethodByID), arg0, arg1)
+}
+
// UpdateOAuth2ProviderAppByID mocks base method.
func (m *MockStore) UpdateOAuth2ProviderAppByID(arg0 context.Context, arg1 database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
m.ctrl.T.Helper()
@@ -4504,6 +4564,21 @@ func (mr *MockStoreMockRecorder) UpdateUserLoginType(arg0, arg1 any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLoginType", reflect.TypeOf((*MockStore)(nil).UpdateUserLoginType), arg0, arg1)
}
+// UpdateUserNotificationPreferences mocks base method.
+func (m *MockStore) UpdateUserNotificationPreferences(arg0 context.Context, arg1 database.UpdateUserNotificationPreferencesParams) (int64, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateUserNotificationPreferences", arg0, arg1)
+ ret0, _ := ret[0].(int64)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// UpdateUserNotificationPreferences indicates an expected call of UpdateUserNotificationPreferences.
+func (mr *MockStoreMockRecorder) UpdateUserNotificationPreferences(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).UpdateUserNotificationPreferences), arg0, arg1)
+}
+
// UpdateUserProfile mocks base method.
func (m *MockStore) UpdateUserProfile(arg0 context.Context, arg1 database.UpdateUserProfileParams) (database.User, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index c3b74732dd825..0bcad08271da5 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -84,7 +84,8 @@ CREATE TYPE notification_message_status AS ENUM (
'sent',
'permanent_failure',
'temporary_failure',
- 'unknown'
+ 'unknown',
+ 'inhibited'
);
CREATE TYPE notification_method AS ENUM (
@@ -92,6 +93,10 @@ CREATE TYPE notification_method AS ENUM (
'webhook'
);
+CREATE TYPE notification_template_kind AS ENUM (
+ 'system'
+);
+
CREATE TYPE parameter_destination_scheme AS ENUM (
'none',
'environment_variable',
@@ -164,7 +169,8 @@ CREATE TYPE resource_type AS ENUM (
'oauth2_provider_app_secret',
'custom_role',
'organization_member',
- 'notifications_settings'
+ 'notifications_settings',
+ 'notification_template'
);
CREATE TYPE startup_script_behavior AS ENUM (
@@ -249,6 +255,23 @@ BEGIN
END;
$$;
+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';
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
CREATE FUNCTION insert_apikey_fail_if_user_deleted() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -567,17 +590,29 @@ CREATE TABLE notification_messages (
queued_seconds double precision
);
+CREATE TABLE notification_preferences (
+ user_id uuid NOT NULL,
+ notification_template_id uuid NOT NULL,
+ disabled boolean DEFAULT false NOT NULL,
+ created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
+);
+
CREATE TABLE notification_templates (
id uuid NOT NULL,
name text NOT NULL,
title_template text NOT NULL,
body_template text NOT NULL,
actions jsonb,
- "group" text
+ "group" text,
+ method notification_method,
+ kind notification_template_kind DEFAULT 'system'::notification_template_kind NOT NULL
);
COMMENT ON TABLE notification_templates IS 'Templates from which to create notification messages.';
+COMMENT ON COLUMN notification_templates.method IS 'NULL defers to the deployment-level method';
+
CREATE TABLE oauth2_provider_app_codes (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
@@ -1536,6 +1571,9 @@ ALTER TABLE ONLY licenses
ALTER TABLE ONLY notification_messages
ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY notification_preferences
+ ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id);
+
ALTER TABLE ONLY notification_templates
ADD CONSTRAINT notification_templates_name_key UNIQUE (name);
@@ -1798,6 +1836,8 @@ CREATE INDEX workspace_resources_job_id_idx ON workspace_resources USING btree (
CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);
+CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_messages FOR EACH ROW EXECUTE FUNCTION inhibit_enqueue_if_disabled();
+
CREATE TRIGGER tailnet_notify_agent_change AFTER INSERT OR DELETE OR UPDATE ON tailnet_agents FOR EACH ROW EXECUTE FUNCTION tailnet_notify_agent_change();
CREATE TRIGGER tailnet_notify_client_change AFTER INSERT OR DELETE OR UPDATE ON tailnet_clients FOR EACH ROW EXECUTE FUNCTION tailnet_notify_client_change();
@@ -1851,6 +1891,12 @@ ALTER TABLE ONLY notification_messages
ALTER TABLE ONLY notification_messages
ADD CONSTRAINT notification_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+ALTER TABLE ONLY notification_preferences
+ ADD CONSTRAINT notification_preferences_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY notification_preferences
+ ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY oauth2_provider_app_codes
ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE;
diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go
index 6e6eef8862b72..011d39bdc5b91 100644
--- a/coderd/database/foreign_key_constraint.go
+++ b/coderd/database/foreign_key_constraint.go
@@ -17,6 +17,8 @@ const (
ForeignKeyJfrogXrayScansWorkspaceID ForeignKeyConstraint = "jfrog_xray_scans_workspace_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
ForeignKeyNotificationMessagesNotificationTemplateID ForeignKeyConstraint = "notification_messages_notification_template_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE;
ForeignKeyNotificationMessagesUserID ForeignKeyConstraint = "notification_messages_user_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+ ForeignKeyNotificationPreferencesNotificationTemplateID ForeignKeyConstraint = "notification_preferences_notification_template_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE;
+ ForeignKeyNotificationPreferencesUserID ForeignKeyConstraint = "notification_preferences_user_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyOauth2ProviderAppCodesAppID ForeignKeyConstraint = "oauth2_provider_app_codes_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE;
ForeignKeyOauth2ProviderAppCodesUserID ForeignKeyConstraint = "oauth2_provider_app_codes_user_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyOauth2ProviderAppSecretsAppID ForeignKeyConstraint = "oauth2_provider_app_secrets_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE;
diff --git a/coderd/database/migrations/000238_notification_preferences.down.sql b/coderd/database/migrations/000238_notification_preferences.down.sql
new file mode 100644
index 0000000000000..5e894d93e5289
--- /dev/null
+++ b/coderd/database/migrations/000238_notification_preferences.down.sql
@@ -0,0 +1,9 @@
+ALTER TABLE notification_templates
+ DROP COLUMN IF EXISTS method,
+ DROP COLUMN IF EXISTS kind;
+
+DROP TABLE IF EXISTS notification_preferences;
+DROP TYPE IF EXISTS notification_template_kind;
+
+DROP TRIGGER IF EXISTS inhibit_enqueue_if_disabled ON notification_messages;
+DROP FUNCTION IF EXISTS inhibit_enqueue_if_disabled;
diff --git a/coderd/database/migrations/000238_notification_preferences.up.sql b/coderd/database/migrations/000238_notification_preferences.up.sql
new file mode 100644
index 0000000000000..c6e38a3ab69fd
--- /dev/null
+++ b/coderd/database/migrations/000238_notification_preferences.up.sql
@@ -0,0 +1,52 @@
+CREATE TABLE notification_preferences
+(
+ user_id uuid REFERENCES users ON DELETE CASCADE NOT NULL,
+ notification_template_id uuid REFERENCES notification_templates ON DELETE CASCADE NOT NULL,
+ disabled bool NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, notification_template_id)
+);
+
+-- Add a new type (to be expanded upon later) which specifies the kind of notification template.
+CREATE TYPE notification_template_kind AS ENUM (
+ 'system'
+ );
+
+ALTER TABLE notification_templates
+ -- Allow per-template notification method (enterprise only).
+ ADD COLUMN method notification_method,
+ -- Update all existing notification templates to be system templates.
+ ADD COLUMN kind notification_template_kind DEFAULT 'system'::notification_template_kind NOT NULL;
+COMMENT ON COLUMN notification_templates.method IS 'NULL defers to the deployment-level method';
+
+-- No equivalent in down migration because ENUM values cannot be deleted.
+ALTER TYPE notification_message_status ADD VALUE IF NOT EXISTS 'inhibited';
+
+-- Function to prevent enqueuing notifications unnecessarily.
+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;
+
+-- Trigger to execute above function on insertion.
+CREATE TRIGGER inhibit_enqueue_if_disabled
+ BEFORE INSERT
+ ON notification_messages
+ FOR EACH ROW
+EXECUTE FUNCTION inhibit_enqueue_if_disabled();
+
+-- Allow modifications to notification templates to be audited.
+ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'notification_template';
diff --git a/coderd/database/migrations/testdata/fixtures/000238_notifications_preferences.up.sql b/coderd/database/migrations/testdata/fixtures/000238_notifications_preferences.up.sql
new file mode 100644
index 0000000000000..74b70cf29792e
--- /dev/null
+++ b/coderd/database/migrations/testdata/fixtures/000238_notifications_preferences.up.sql
@@ -0,0 +1,5 @@
+INSERT INTO notification_templates (id, name, title_template, body_template, "group")
+VALUES ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'A', 'title', 'body', 'Group 1') ON CONFLICT DO NOTHING;
+
+INSERT INTO notification_preferences (user_id, notification_template_id, disabled, created_at, updated_at)
+VALUES ('a0061a8e-7db7-4585-838c-3116a003dd21', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', FALSE, '2024-07-15 10:30:00+00', '2024-07-15 10:30:00+00');
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 70350f54a704f..4bd012e258fbd 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -669,6 +669,7 @@ const (
NotificationMessageStatusPermanentFailure NotificationMessageStatus = "permanent_failure"
NotificationMessageStatusTemporaryFailure NotificationMessageStatus = "temporary_failure"
NotificationMessageStatusUnknown NotificationMessageStatus = "unknown"
+ NotificationMessageStatusInhibited NotificationMessageStatus = "inhibited"
)
func (e *NotificationMessageStatus) Scan(src interface{}) error {
@@ -713,7 +714,8 @@ func (e NotificationMessageStatus) Valid() bool {
NotificationMessageStatusSent,
NotificationMessageStatusPermanentFailure,
NotificationMessageStatusTemporaryFailure,
- NotificationMessageStatusUnknown:
+ NotificationMessageStatusUnknown,
+ NotificationMessageStatusInhibited:
return true
}
return false
@@ -727,6 +729,7 @@ func AllNotificationMessageStatusValues() []NotificationMessageStatus {
NotificationMessageStatusPermanentFailure,
NotificationMessageStatusTemporaryFailure,
NotificationMessageStatusUnknown,
+ NotificationMessageStatusInhibited,
}
}
@@ -788,6 +791,61 @@ func AllNotificationMethodValues() []NotificationMethod {
}
}
+type NotificationTemplateKind string
+
+const (
+ NotificationTemplateKindSystem NotificationTemplateKind = "system"
+)
+
+func (e *NotificationTemplateKind) Scan(src interface{}) error {
+ switch s := src.(type) {
+ case []byte:
+ *e = NotificationTemplateKind(s)
+ case string:
+ *e = NotificationTemplateKind(s)
+ default:
+ return fmt.Errorf("unsupported scan type for NotificationTemplateKind: %T", src)
+ }
+ return nil
+}
+
+type NullNotificationTemplateKind struct {
+ NotificationTemplateKind NotificationTemplateKind `json:"notification_template_kind"`
+ Valid bool `json:"valid"` // Valid is true if NotificationTemplateKind is not NULL
+}
+
+// Scan implements the Scanner interface.
+func (ns *NullNotificationTemplateKind) Scan(value interface{}) error {
+ if value == nil {
+ ns.NotificationTemplateKind, ns.Valid = "", false
+ return nil
+ }
+ ns.Valid = true
+ return ns.NotificationTemplateKind.Scan(value)
+}
+
+// Value implements the driver Valuer interface.
+func (ns NullNotificationTemplateKind) Value() (driver.Value, error) {
+ if !ns.Valid {
+ return nil, nil
+ }
+ return string(ns.NotificationTemplateKind), nil
+}
+
+func (e NotificationTemplateKind) Valid() bool {
+ switch e {
+ case NotificationTemplateKindSystem:
+ return true
+ }
+ return false
+}
+
+func AllNotificationTemplateKindValues() []NotificationTemplateKind {
+ return []NotificationTemplateKind{
+ NotificationTemplateKindSystem,
+ }
+}
+
type ParameterDestinationScheme string
const (
@@ -1353,6 +1411,7 @@ const (
ResourceTypeCustomRole ResourceType = "custom_role"
ResourceTypeOrganizationMember ResourceType = "organization_member"
ResourceTypeNotificationsSettings ResourceType = "notifications_settings"
+ ResourceTypeNotificationTemplate ResourceType = "notification_template"
)
func (e *ResourceType) Scan(src interface{}) error {
@@ -1409,7 +1468,8 @@ func (e ResourceType) Valid() bool {
ResourceTypeOauth2ProviderAppSecret,
ResourceTypeCustomRole,
ResourceTypeOrganizationMember,
- ResourceTypeNotificationsSettings:
+ ResourceTypeNotificationsSettings,
+ ResourceTypeNotificationTemplate:
return true
}
return false
@@ -1435,6 +1495,7 @@ func AllResourceTypeValues() []ResourceType {
ResourceTypeCustomRole,
ResourceTypeOrganizationMember,
ResourceTypeNotificationsSettings,
+ ResourceTypeNotificationTemplate,
}
}
@@ -2034,6 +2095,14 @@ type NotificationMessage struct {
QueuedSeconds sql.NullFloat64 `db:"queued_seconds" json:"queued_seconds"`
}
+type NotificationPreference struct {
+ UserID uuid.UUID `db:"user_id" json:"user_id"`
+ NotificationTemplateID uuid.UUID `db:"notification_template_id" json:"notification_template_id"`
+ Disabled bool `db:"disabled" json:"disabled"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
+
// Templates from which to create notification messages.
type NotificationTemplate struct {
ID uuid.UUID `db:"id" json:"id"`
@@ -2042,6 +2111,9 @@ type NotificationTemplate struct {
BodyTemplate string `db:"body_template" json:"body_template"`
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"`
}
// A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 95015aa706348..2d45e154b532d 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -162,6 +162,8 @@ type sqlcQuerier interface {
GetLicenses(ctx context.Context) ([]License, error)
GetLogoURL(ctx context.Context) (string, error)
GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error)
+ GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (NotificationTemplate, error)
+ GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error)
GetNotificationsSettings(ctx context.Context) (string, error)
GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error)
@@ -265,6 +267,7 @@ type sqlcQuerier interface {
GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error)
GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error)
GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error)
+ GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error)
GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error)
// This will never return deleted users.
GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error)
@@ -401,6 +404,7 @@ type sqlcQuerier interface {
UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error)
UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error)
UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error)
+ UpdateNotificationTemplateMethodByID(ctx context.Context, arg UpdateNotificationTemplateMethodByIDParams) (NotificationTemplate, error)
UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error)
UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error)
UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error)
@@ -427,6 +431,7 @@ type sqlcQuerier interface {
UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error)
UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error)
UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error)
+ UpdateUserNotificationPreferences(ctx context.Context, arg UpdateUserNotificationPreferencesParams) (int64, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error)
UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error)
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 4e7e0ceb3150d..d8a6e3a1abb03 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -3335,14 +3335,18 @@ SELECT
nm.id,
nm.payload,
nm.method,
- nm.attempt_count::int AS attempt_count,
- nm.queued_seconds::float AS queued_seconds,
+ nm.attempt_count::int AS attempt_count,
+ nm.queued_seconds::float AS queued_seconds,
-- template
- nt.id AS template_id,
+ nt.id AS template_id,
nt.title_template,
- nt.body_template
+ nt.body_template,
+ -- preferences
+ (CASE WHEN np.disabled IS NULL THEN false ELSE np.disabled END)::bool AS disabled
FROM acquired nm
JOIN notification_templates nt ON nm.notification_template_id = nt.id
+ LEFT JOIN notification_preferences AS np
+ ON (np.user_id = nm.user_id AND np.notification_template_id = nm.notification_template_id)
`
type AcquireNotificationMessagesParams struct {
@@ -3361,6 +3365,7 @@ type AcquireNotificationMessagesRow struct {
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
TitleTemplate string `db:"title_template" json:"title_template"`
BodyTemplate string `db:"body_template" json:"body_template"`
+ Disabled bool `db:"disabled" json:"disabled"`
}
// Acquires the lease for a given count of notification messages, to enable concurrent dequeuing and subsequent sending.
@@ -3396,6 +3401,7 @@ func (q *sqlQuerier) AcquireNotificationMessages(ctx context.Context, arg Acquir
&i.TemplateID,
&i.TitleTemplate,
&i.BodyTemplate,
+ &i.Disabled,
); err != nil {
return nil, err
}
@@ -3534,10 +3540,11 @@ func (q *sqlQuerier) EnqueueNotificationMessage(ctx context.Context, arg Enqueue
const fetchNewMessageMetadata = `-- name: FetchNewMessageMetadata :one
SELECT nt.name AS notification_name,
nt.actions AS actions,
+ nt.method AS custom_method,
u.id AS user_id,
u.email AS user_email,
COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''))::text AS user_name,
- COALESCE(u.username, '') AS user_username
+ u.username AS user_username
FROM notification_templates nt,
users u
WHERE nt.id = $1
@@ -3550,12 +3557,13 @@ type FetchNewMessageMetadataParams struct {
}
type FetchNewMessageMetadataRow struct {
- NotificationName string `db:"notification_name" json:"notification_name"`
- Actions []byte `db:"actions" json:"actions"`
- 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"`
+ 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.
@@ -3565,6 +3573,7 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe
err := row.Scan(
&i.NotificationName,
&i.Actions,
+ &i.CustomMethod,
&i.UserID,
&i.UserEmail,
&i.UserName,
@@ -3574,7 +3583,10 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe
}
const getNotificationMessagesByStatus = `-- name: GetNotificationMessagesByStatus :many
-SELECT id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after, queued_seconds FROM notification_messages WHERE status = $1 LIMIT $2::int
+SELECT id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after, queued_seconds
+FROM notification_messages
+WHERE status = $1
+LIMIT $2::int
`
type GetNotificationMessagesByStatusParams struct {
@@ -3621,6 +3633,154 @@ func (q *sqlQuerier) GetNotificationMessagesByStatus(ctx context.Context, arg Ge
return items, nil
}
+const getNotificationTemplateByID = `-- name: GetNotificationTemplateByID :one
+SELECT id, name, title_template, body_template, actions, "group", method, kind
+FROM notification_templates
+WHERE id = $1::uuid
+`
+
+func (q *sqlQuerier) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (NotificationTemplate, error) {
+ row := q.db.QueryRowContext(ctx, getNotificationTemplateByID, id)
+ var i NotificationTemplate
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.TitleTemplate,
+ &i.BodyTemplate,
+ &i.Actions,
+ &i.Group,
+ &i.Method,
+ &i.Kind,
+ )
+ return i, err
+}
+
+const getNotificationTemplatesByKind = `-- name: GetNotificationTemplatesByKind :many
+SELECT id, name, title_template, body_template, actions, "group", method, kind
+FROM notification_templates
+WHERE kind = $1::notification_template_kind
+`
+
+func (q *sqlQuerier) GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) {
+ rows, err := q.db.QueryContext(ctx, getNotificationTemplatesByKind, kind)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []NotificationTemplate
+ for rows.Next() {
+ var i NotificationTemplate
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.TitleTemplate,
+ &i.BodyTemplate,
+ &i.Actions,
+ &i.Group,
+ &i.Method,
+ &i.Kind,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getUserNotificationPreferences = `-- name: GetUserNotificationPreferences :many
+SELECT user_id, notification_template_id, disabled, created_at, updated_at
+FROM notification_preferences
+WHERE user_id = $1::uuid
+`
+
+func (q *sqlQuerier) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) {
+ rows, err := q.db.QueryContext(ctx, getUserNotificationPreferences, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []NotificationPreference
+ for rows.Next() {
+ var i NotificationPreference
+ if err := rows.Scan(
+ &i.UserID,
+ &i.NotificationTemplateID,
+ &i.Disabled,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateNotificationTemplateMethodByID = `-- name: UpdateNotificationTemplateMethodByID :one
+UPDATE notification_templates
+SET method = $1::notification_method
+WHERE id = $2::uuid
+RETURNING id, name, title_template, body_template, actions, "group", method, kind
+`
+
+type UpdateNotificationTemplateMethodByIDParams struct {
+ Method NullNotificationMethod `db:"method" json:"method"`
+ ID uuid.UUID `db:"id" json:"id"`
+}
+
+func (q *sqlQuerier) UpdateNotificationTemplateMethodByID(ctx context.Context, arg UpdateNotificationTemplateMethodByIDParams) (NotificationTemplate, error) {
+ row := q.db.QueryRowContext(ctx, updateNotificationTemplateMethodByID, arg.Method, arg.ID)
+ var i NotificationTemplate
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.TitleTemplate,
+ &i.BodyTemplate,
+ &i.Actions,
+ &i.Group,
+ &i.Method,
+ &i.Kind,
+ )
+ return i, err
+}
+
+const updateUserNotificationPreferences = `-- name: UpdateUserNotificationPreferences :execrows
+INSERT
+INTO notification_preferences (user_id, notification_template_id, disabled)
+SELECT $1::uuid, new_values.notification_template_id, new_values.disabled
+FROM (SELECT UNNEST($2::uuid[]) AS notification_template_id,
+ UNNEST($3::bool[]) AS disabled) AS new_values
+ON CONFLICT (user_id, notification_template_id) DO UPDATE
+ SET disabled = EXCLUDED.disabled,
+ updated_at = CURRENT_TIMESTAMP
+`
+
+type UpdateUserNotificationPreferencesParams struct {
+ UserID uuid.UUID `db:"user_id" json:"user_id"`
+ NotificationTemplateIds []uuid.UUID `db:"notification_template_ids" json:"notification_template_ids"`
+ Disableds []bool `db:"disableds" json:"disableds"`
+}
+
+func (q *sqlQuerier) UpdateUserNotificationPreferences(ctx context.Context, arg UpdateUserNotificationPreferencesParams) (int64, error) {
+ result, err := q.db.ExecContext(ctx, updateUserNotificationPreferences, arg.UserID, pq.Array(arg.NotificationTemplateIds), pq.Array(arg.Disableds))
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected()
+}
+
const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec
DELETE FROM oauth2_provider_apps WHERE id = $1
`
diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql
index c0a2f25323957..f5b8601871ccc 100644
--- a/coderd/database/queries/notifications.sql
+++ b/coderd/database/queries/notifications.sql
@@ -2,10 +2,11 @@
-- This is used to build up the notification_message's JSON payload.
SELECT nt.name AS notification_name,
nt.actions AS actions,
+ nt.method AS custom_method,
u.id AS user_id,
u.email AS user_email,
COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''))::text AS user_name,
- COALESCE(u.username, '') AS user_username
+ u.username AS user_username
FROM notification_templates nt,
users u
WHERE nt.id = @notification_template_id
@@ -79,14 +80,18 @@ SELECT
nm.id,
nm.payload,
nm.method,
- nm.attempt_count::int AS attempt_count,
- nm.queued_seconds::float AS queued_seconds,
+ nm.attempt_count::int AS attempt_count,
+ nm.queued_seconds::float AS queued_seconds,
-- template
- nt.id AS template_id,
+ nt.id AS template_id,
nt.title_template,
- nt.body_template
+ nt.body_template,
+ -- preferences
+ (CASE WHEN np.disabled IS NULL THEN false ELSE np.disabled END)::bool AS disabled
FROM acquired nm
- JOIN notification_templates nt ON nm.notification_template_id = nt.id;
+ JOIN notification_templates nt ON nm.notification_template_id = nt.id
+ LEFT JOIN notification_preferences AS np
+ ON (np.user_id = nm.user_id AND np.notification_template_id = nm.notification_template_id);
-- name: BulkMarkNotificationMessagesFailed :execrows
UPDATE notification_messages
@@ -131,4 +136,38 @@ WHERE id IN
WHERE nested.updated_at < NOW() - INTERVAL '7 days');
-- name: GetNotificationMessagesByStatus :many
-SELECT * FROM notification_messages WHERE status = @status LIMIT sqlc.arg('limit')::int;
+SELECT *
+FROM notification_messages
+WHERE status = @status
+LIMIT sqlc.arg('limit')::int;
+
+-- name: GetUserNotificationPreferences :many
+SELECT *
+FROM notification_preferences
+WHERE user_id = @user_id::uuid;
+
+-- name: UpdateUserNotificationPreferences :execrows
+INSERT
+INTO notification_preferences (user_id, notification_template_id, disabled)
+SELECT @user_id::uuid, new_values.notification_template_id, new_values.disabled
+FROM (SELECT UNNEST(@notification_template_ids::uuid[]) AS notification_template_id,
+ UNNEST(@disableds::bool[]) AS disabled) AS new_values
+ON CONFLICT (user_id, notification_template_id) DO UPDATE
+ SET disabled = EXCLUDED.disabled,
+ updated_at = CURRENT_TIMESTAMP;
+
+-- name: UpdateNotificationTemplateMethodByID :one
+UPDATE notification_templates
+SET method = sqlc.narg('method')::notification_method
+WHERE id = @id::uuid
+RETURNING *;
+
+-- name: GetNotificationTemplateByID :one
+SELECT *
+FROM notification_templates
+WHERE id = @id::uuid;
+
+-- name: GetNotificationTemplatesByKind :many
+SELECT *
+FROM notification_templates
+WHERE kind = @kind::notification_template_kind;
diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go
index aecae02d572ff..f3f42ea0b72ad 100644
--- a/coderd/database/unique_constraint.go
+++ b/coderd/database/unique_constraint.go
@@ -24,6 +24,7 @@ const (
UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt);
UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id);
UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id);
+ UniqueNotificationPreferencesPkey UniqueConstraint = "notification_preferences_pkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id);
UniqueNotificationTemplatesNameKey UniqueConstraint = "notification_templates_name_key" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name);
UniqueNotificationTemplatesPkey UniqueConstraint = "notification_templates_pkey" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_pkey PRIMARY KEY (id);
UniqueOauth2ProviderAppCodesPkey UniqueConstraint = "oauth2_provider_app_codes_pkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_pkey PRIMARY KEY (id);
diff --git a/coderd/httpmw/notificationtemplateparam.go b/coderd/httpmw/notificationtemplateparam.go
new file mode 100644
index 0000000000000..5466c3b7403d9
--- /dev/null
+++ b/coderd/httpmw/notificationtemplateparam.go
@@ -0,0 +1,49 @@
+package httpmw
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/httpapi"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+type notificationTemplateParamContextKey struct{}
+
+// NotificationTemplateParam returns the template from the ExtractNotificationTemplateParam handler.
+func NotificationTemplateParam(r *http.Request) database.NotificationTemplate {
+ template, ok := r.Context().Value(notificationTemplateParamContextKey{}).(database.NotificationTemplate)
+ if !ok {
+ panic("developer error: notification template middleware not used")
+ }
+ return template
+}
+
+// ExtractNotificationTemplateParam grabs a notification template from the "notification_template" URL parameter.
+func ExtractNotificationTemplateParam(db database.Store) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ notifTemplateID, parsed := ParseUUIDParam(rw, r, "notification_template")
+ if !parsed {
+ return
+ }
+ nt, err := db.GetNotificationTemplateByID(r.Context(), notifTemplateID)
+ if httpapi.Is404Error(err) {
+ httpapi.ResourceNotFound(rw)
+ return
+ }
+ if err != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error fetching notification template.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ ctx = context.WithValue(ctx, notificationTemplateParamContextKey{}, nt)
+ next.ServeHTTP(rw, r.WithContext(ctx))
+ })
+ }
+}
diff --git a/coderd/notifications.go b/coderd/notifications.go
index f6bcbe0c7183d..bdf71f99cab98 100644
--- a/coderd/notifications.go
+++ b/coderd/notifications.go
@@ -7,11 +7,13 @@ import (
"github.com/google/uuid"
+ "cdr.dev/slog"
+
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
+ "github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
- "github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)
@@ -19,7 +21,7 @@ import (
// @ID get-notifications-settings
// @Security CoderSessionToken
// @Produce json
-// @Tags General
+// @Tags Notifications
// @Success 200 {object} codersdk.NotificationsSettings
// @Router /notifications/settings [get]
func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) {
@@ -51,7 +53,7 @@ func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) {
// @Security CoderSessionToken
// @Accept json
// @Produce json
-// @Tags General
+// @Tags Notifications
// @Param request body codersdk.NotificationsSettings true "Notifications settings request"
// @Success 200 {object} codersdk.NotificationsSettings
// @Success 304
@@ -59,13 +61,6 @@ func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) {
func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
- httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
- Message: "Insufficient permissions to update notifications settings.",
- })
- return
- }
-
var settings codersdk.NotificationsSettings
if !httpapi.Read(ctx, rw, r, &settings) {
return
@@ -80,9 +75,9 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request
return
}
- currentSettingsJSON, err := api.Database.GetNotificationsSettings(r.Context())
+ currentSettingsJSON, err := api.Database.GetNotificationsSettings(ctx)
if err != nil {
- httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch current notifications settings.",
Detail: err.Error(),
})
@@ -91,7 +86,7 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request
if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) {
// See: https://www.rfc-editor.org/rfc/rfc7232#section-4.1
- httpapi.Write(r.Context(), rw, http.StatusNotModified, nil)
+ httpapi.Write(ctx, rw, http.StatusNotModified, nil)
return
}
@@ -111,12 +106,193 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request
err = api.Database.UpsertNotificationsSettings(ctx, string(settingsJSON))
if err != nil {
- httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
+ if rbac.IsUnauthorizedError(err) {
+ httpapi.Forbidden(rw)
+ return
+ }
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update notifications settings.",
Detail: err.Error(),
})
+
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, settings)
}
+
+// @Summary Get system notification templates
+// @ID get-system-notification-templates
+// @Security CoderSessionToken
+// @Produce json
+// @Tags Notifications
+// @Success 200 {array} codersdk.NotificationTemplate
+// @Router /notifications/templates/system [get]
+func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ templates, err := api.Database.GetNotificationTemplatesByKind(ctx, database.NotificationTemplateKindSystem)
+ if err != nil {
+ httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to retrieve system notifications templates.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ out := convertNotificationTemplates(templates)
+ httpapi.Write(r.Context(), rw, http.StatusOK, out)
+}
+
+// @Summary Get notification dispatch methods
+// @ID get-notification-dispatch-methods
+// @Security CoderSessionToken
+// @Produce json
+// @Tags Notifications
+// @Success 200 {array} codersdk.NotificationMethodsResponse
+// @Router /notifications/dispatch-methods [get]
+func (api *API) notificationDispatchMethods(rw http.ResponseWriter, r *http.Request) {
+ var methods []string
+ for _, nm := range database.AllNotificationMethodValues() {
+ methods = append(methods, string(nm))
+ }
+
+ httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.NotificationMethodsResponse{
+ AvailableNotificationMethods: methods,
+ DefaultNotificationMethod: api.DeploymentValues.Notifications.Method.Value(),
+ })
+}
+
+// @Summary Get user notification preferences
+// @ID get-user-notification-preferences
+// @Security CoderSessionToken
+// @Produce json
+// @Tags Notifications
+// @Param user path string true "User ID, name, or me"
+// @Success 200 {array} codersdk.NotificationPreference
+// @Router /users/{user}/notifications/preferences [get]
+func (api *API) userNotificationPreferences(rw http.ResponseWriter, r *http.Request) {
+ var (
+ ctx = r.Context()
+ user = httpmw.UserParam(r)
+ logger = api.Logger.Named("notifications.preferences").With(slog.F("user_id", user.ID))
+ )
+
+ prefs, err := api.Database.GetUserNotificationPreferences(ctx, user.ID)
+ if err != nil {
+ logger.Error(ctx, "failed to retrieve preferences", slog.Error(err))
+
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to retrieve user notification preferences.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ out := convertNotificationPreferences(prefs)
+ httpapi.Write(ctx, rw, http.StatusOK, out)
+}
+
+// @Summary Update user notification preferences
+// @ID update-user-notification-preferences
+// @Security CoderSessionToken
+// @Accept json
+// @Produce json
+// @Tags Notifications
+// @Param request body codersdk.UpdateUserNotificationPreferences true "Preferences"
+// @Param user path string true "User ID, name, or me"
+// @Success 200 {array} codersdk.NotificationPreference
+// @Router /users/{user}/notifications/preferences [put]
+func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.Request) {
+ var (
+ ctx = r.Context()
+ user = httpmw.UserParam(r)
+ logger = api.Logger.Named("notifications.preferences").With(slog.F("user_id", user.ID))
+ )
+
+ // Parse request.
+ var prefs codersdk.UpdateUserNotificationPreferences
+ if !httpapi.Read(ctx, rw, r, &prefs) {
+ return
+ }
+
+ // Build query params.
+ input := database.UpdateUserNotificationPreferencesParams{
+ UserID: user.ID,
+ NotificationTemplateIds: make([]uuid.UUID, 0, len(prefs.TemplateDisabledMap)),
+ Disableds: make([]bool, 0, len(prefs.TemplateDisabledMap)),
+ }
+ for tmplID, disabled := range prefs.TemplateDisabledMap {
+ id, err := uuid.Parse(tmplID)
+ if err != nil {
+ logger.Warn(ctx, "failed to parse notification template UUID", slog.F("input", tmplID), slog.Error(err))
+
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Unable to parse notification template UUID.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ input.NotificationTemplateIds = append(input.NotificationTemplateIds, id)
+ input.Disableds = append(input.Disableds, disabled)
+ }
+
+ // Update preferences with params.
+ updated, err := api.Database.UpdateUserNotificationPreferences(ctx, input)
+ if err != nil {
+ logger.Error(ctx, "failed to update preferences", slog.Error(err))
+
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to update user notifications preferences.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ // Preferences updated, now fetch all preferences belonging to this user.
+ logger.Info(ctx, "updated preferences", slog.F("count", updated))
+
+ userPrefs, err := api.Database.GetUserNotificationPreferences(ctx, user.ID)
+ if err != nil {
+ logger.Error(ctx, "failed to retrieve preferences", slog.Error(err))
+
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to retrieve user notifications preferences.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ out := convertNotificationPreferences(userPrefs)
+ httpapi.Write(ctx, rw, http.StatusOK, out)
+}
+
+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),
+ })
+ }
+
+ return out
+}
+
+func convertNotificationPreferences(in []database.NotificationPreference) (out []codersdk.NotificationPreference) {
+ for _, pref := range in {
+ out = append(out, codersdk.NotificationPreference{
+ NotificationTemplateID: pref.NotificationTemplateID,
+ Disabled: pref.Disabled,
+ UpdatedAt: pref.UpdatedAt,
+ })
+ }
+
+ return out
+}
diff --git a/coderd/notifications/enqueuer.go b/coderd/notifications/enqueuer.go
index 32822dd6ab9d7..d990a71bdb5ad 100644
--- a/coderd/notifications/enqueuer.go
+++ b/coderd/notifications/enqueuer.go
@@ -3,6 +3,7 @@ package notifications
import (
"context"
"encoding/json"
+ "strings"
"text/template"
"github.com/google/uuid"
@@ -16,14 +17,13 @@ import (
"github.com/coder/coder/v2/codersdk"
)
+var ErrCannotEnqueueDisabledNotification = xerrors.New("user has disabled this notification")
+
type StoreEnqueuer struct {
store Store
log slog.Logger
- // TODO: expand this to allow for each notification to have custom delivery methods, or multiple, or none.
- // For example, Larry might want email notifications for "workspace deleted" notifications, but Harry wants
- // Slack notifications, and Mary doesn't want any.
- method database.NotificationMethod
+ defaultMethod database.NotificationMethod
// helpers holds a map of template funcs which are used when rendering templates. These need to be passed in because
// the template funcs will return values which are inappropriately encapsulated in this struct.
helpers template.FuncMap
@@ -37,17 +37,31 @@ func NewStoreEnqueuer(cfg codersdk.NotificationsConfig, store Store, helpers tem
}
return &StoreEnqueuer{
- store: store,
- log: log,
- method: method,
- helpers: helpers,
+ store: store,
+ log: log,
+ defaultMethod: method,
+ helpers: helpers,
}, nil
}
// Enqueue queues a notification message for later delivery.
// Messages will be dequeued by a notifier later and dispatched.
func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) {
- payload, err := s.buildPayload(ctx, userID, templateID, labels)
+ metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{
+ UserID: userID,
+ NotificationTemplateID: templateID,
+ })
+ if err != nil {
+ s.log.Warn(ctx, "failed to fetch message metadata", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err))
+ return nil, xerrors.Errorf("new message metadata: %w", err)
+ }
+
+ dispatchMethod := s.defaultMethod
+ if metadata.CustomMethod.Valid {
+ dispatchMethod = metadata.CustomMethod.NotificationMethod
+ }
+
+ payload, err := s.buildPayload(metadata, labels)
if err != nil {
s.log.Warn(ctx, "failed to build payload", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err))
return nil, xerrors.Errorf("enqueue notification (payload build): %w", err)
@@ -63,12 +77,21 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI
ID: id,
UserID: userID,
NotificationTemplateID: templateID,
- Method: s.method,
+ Method: dispatchMethod,
Payload: input,
Targets: targets,
CreatedBy: createdBy,
})
if err != nil {
+ // We have a trigger on the notification_messages table named `inhibit_enqueue_if_disabled` which prevents messages
+ // from being enqueued if the user has disabled them via notification_preferences. The trigger will fail the insertion
+ // with the message "cannot enqueue message: user has disabled this notification".
+ //
+ // This is more efficient than fetching the user's preferences for each enqueue, and centralizes the business logic.
+ if strings.Contains(err.Error(), ErrCannotEnqueueDisabledNotification.Error()) {
+ return nil, ErrCannotEnqueueDisabledNotification
+ }
+
s.log.Warn(ctx, "failed to enqueue notification", slog.F("template_id", templateID), slog.F("input", input), slog.Error(err))
return nil, xerrors.Errorf("enqueue notification: %w", err)
}
@@ -80,15 +103,7 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI
// buildPayload creates the payload that the notification will for variable substitution and/or routing.
// The payload contains information about the recipient, the event that triggered the notification, and any subsequent
// actions which can be taken by the recipient.
-func (s *StoreEnqueuer) buildPayload(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string) (*types.MessagePayload, error) {
- metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{
- UserID: userID,
- NotificationTemplateID: templateID,
- })
- if err != nil {
- return nil, xerrors.Errorf("new message metadata: %w", err)
- }
-
+func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRow, labels map[string]string) (*types.MessagePayload, error) {
payload := types.MessagePayload{
Version: "1.0",
diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go
index 5f5d30974a302..91580d5fc4fb7 100644
--- a/coderd/notifications/manager.go
+++ b/coderd/notifications/manager.go
@@ -149,7 +149,7 @@ func (m *Manager) loop(ctx context.Context) error {
var eg errgroup.Group
// Create a notifier to run concurrently, which will handle dequeueing and dispatching notifications.
- m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers, m.method, m.metrics)
+ m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers, m.metrics)
eg.Go(func() error {
return m.notifier.run(ctx, m.success, m.failure)
})
@@ -249,15 +249,24 @@ func (m *Manager) syncUpdates(ctx context.Context) {
for i := 0; i < nFailure; i++ {
res := <-m.failure
- status := database.NotificationMessageStatusPermanentFailure
- if res.retryable {
+ var (
+ reason string
+ status database.NotificationMessageStatus
+ )
+
+ switch {
+ case res.retryable:
status = database.NotificationMessageStatusTemporaryFailure
+ case res.inhibited:
+ status = database.NotificationMessageStatusInhibited
+ reason = "disabled by user"
+ default:
+ status = database.NotificationMessageStatusPermanentFailure
}
failureParams.IDs = append(failureParams.IDs, res.msg)
failureParams.FailedAts = append(failureParams.FailedAts, res.ts)
failureParams.Statuses = append(failureParams.Statuses, status)
- var reason string
if res.err != nil {
reason = res.err.Error()
}
@@ -367,4 +376,5 @@ type dispatchResult struct {
ts time.Time
err error
retryable bool
+ inhibited bool
}
diff --git a/coderd/notifications/metrics_test.go b/coderd/notifications/metrics_test.go
index 6c360dd2919d0..139f7ae18c6c6 100644
--- a/coderd/notifications/metrics_test.go
+++ b/coderd/notifications/metrics_test.go
@@ -339,6 +339,81 @@ func TestInflightDispatchesMetric(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast)
}
+func TestCustomMethodMetricCollection(t *testing.T) {
+ t.Parallel()
+
+ // SETUP
+ if !dbtestutil.WillUsePostgres() {
+ // UpdateNotificationTemplateMethodByID only makes sense with a real database.
+ t.Skip("This test requires postgres; it relies on business-logic only implemented in the database")
+ }
+ ctx, logger, store := setup(t)
+
+ var (
+ reg = prometheus.NewRegistry()
+ metrics = notifications.NewMetrics(reg)
+ template = notifications.TemplateWorkspaceDeleted
+ anotherTemplate = notifications.TemplateWorkspaceDormant
+ )
+
+ const (
+ customMethod = database.NotificationMethodWebhook
+ defaultMethod = database.NotificationMethodSmtp
+ )
+
+ // GIVEN: a template whose notification method differs from the default.
+ out, err := store.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{
+ ID: template,
+ Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true},
+ })
+ require.NoError(t, err)
+ require.Equal(t, customMethod, out.Method.NotificationMethod)
+
+ // WHEN: two notifications (each with different templates) are enqueued.
+ cfg := defaultNotificationsConfig(defaultMethod)
+ mgr, err := notifications.NewManager(cfg, store, metrics, logger.Named("manager"))
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ assert.NoError(t, mgr.Stop(ctx))
+ })
+
+ smtpHandler := &fakeHandler{}
+ webhookHandler := &fakeHandler{}
+ mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
+ defaultMethod: smtpHandler,
+ customMethod: webhookHandler,
+ })
+
+ enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"))
+ require.NoError(t, err)
+
+ user := createSampleUser(t, store)
+
+ _, err = enq.Enqueue(ctx, user.ID, template, map[string]string{"type": "success"}, "test")
+ require.NoError(t, err)
+ _, err = enq.Enqueue(ctx, user.ID, anotherTemplate, map[string]string{"type": "success"}, "test")
+ require.NoError(t, err)
+
+ mgr.Run(ctx)
+
+ // THEN: the fake handlers to "dispatch" the notifications.
+ require.Eventually(t, func() bool {
+ smtpHandler.mu.RLock()
+ webhookHandler.mu.RLock()
+ defer smtpHandler.mu.RUnlock()
+ defer webhookHandler.mu.RUnlock()
+
+ return len(smtpHandler.succeeded) == 1 && len(smtpHandler.failed) == 0 &&
+ len(webhookHandler.succeeded) == 1 && len(webhookHandler.failed) == 0
+ }, testutil.WaitShort, testutil.IntervalFast)
+
+ // THEN: we should have metric series for both the default and custom notification methods.
+ require.Eventually(t, func() bool {
+ return promtest.ToFloat64(metrics.DispatchAttempts.WithLabelValues(string(defaultMethod), anotherTemplate.String(), notifications.ResultSuccess)) > 0 &&
+ promtest.ToFloat64(metrics.DispatchAttempts.WithLabelValues(string(customMethod), template.String(), notifications.ResultSuccess)) > 0
+ }, testutil.WaitShort, testutil.IntervalFast)
+}
+
// hasMatchingFingerprint checks if the given metric's series fingerprint matches the reference fingerprint.
func hasMatchingFingerprint(metric *dto.Metric, fp model.Fingerprint) bool {
return fingerprintLabelPairs(metric.Label) == fp
diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go
index 37fe4a2ce5ce3..d73edbf7c453b 100644
--- a/coderd/notifications/notifications_test.go
+++ b/coderd/notifications/notifications_test.go
@@ -604,7 +604,7 @@ func TestNotifierPaused(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast)
}
-func TestNotifcationTemplatesBody(t *testing.T) {
+func TestNotificationTemplatesBody(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
@@ -705,6 +705,194 @@ func TestNotifcationTemplatesBody(t *testing.T) {
}
}
+// TestDisabledBeforeEnqueue ensures that notifications cannot be enqueued once a user has disabled that notification template
+func TestDisabledBeforeEnqueue(t *testing.T) {
+ t.Parallel()
+
+ // SETUP
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres; it is testing business-logic implemented in the database")
+ }
+
+ ctx, logger, db := setup(t)
+
+ // GIVEN: an enqueuer & a sample user
+ cfg := defaultNotificationsConfig(database.NotificationMethodSmtp)
+ enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer"))
+ require.NoError(t, err)
+ user := createSampleUser(t, db)
+
+ // WHEN: the user has a preference set to not receive the "workspace deleted" notification
+ templateID := notifications.TemplateWorkspaceDeleted
+ n, err := db.UpdateUserNotificationPreferences(ctx, database.UpdateUserNotificationPreferencesParams{
+ UserID: user.ID,
+ NotificationTemplateIds: []uuid.UUID{templateID},
+ Disableds: []bool{true},
+ })
+ require.NoError(t, err, "failed to set preferences")
+ require.EqualValues(t, 1, n, "unexpected number of affected rows")
+
+ // THEN: enqueuing the "workspace deleted" notification should fail with an error
+ _, err = enq.Enqueue(ctx, user.ID, templateID, map[string]string{}, "test")
+ require.ErrorIs(t, err, notifications.ErrCannotEnqueueDisabledNotification, "enqueueing did not fail with expected error")
+}
+
+// TestDisabledAfterEnqueue ensures that notifications enqueued before a notification template was disabled will not be
+// sent, and will instead be marked as "inhibited".
+func TestDisabledAfterEnqueue(t *testing.T) {
+ t.Parallel()
+
+ // SETUP
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres; it is testing business-logic implemented in the database")
+ }
+
+ ctx, logger, db := setup(t)
+
+ method := database.NotificationMethodSmtp
+ cfg := defaultNotificationsConfig(method)
+
+ mgr, err := notifications.NewManager(cfg, db, createMetrics(), logger.Named("manager"))
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ assert.NoError(t, mgr.Stop(ctx))
+ })
+
+ enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer"))
+ require.NoError(t, err)
+ user := createSampleUser(t, db)
+
+ // GIVEN: a notification is enqueued which has not (yet) been disabled
+ templateID := notifications.TemplateWorkspaceDeleted
+ msgID, err := enq.Enqueue(ctx, user.ID, templateID, map[string]string{}, "test")
+ require.NoError(t, err)
+
+ // Disable the notification template.
+ n, err := db.UpdateUserNotificationPreferences(ctx, database.UpdateUserNotificationPreferencesParams{
+ UserID: user.ID,
+ NotificationTemplateIds: []uuid.UUID{templateID},
+ Disableds: []bool{true},
+ })
+ require.NoError(t, err, "failed to set preferences")
+ require.EqualValues(t, 1, n, "unexpected number of affected rows")
+
+ // WHEN: running the manager to trigger dequeueing of (now-disabled) messages
+ mgr.Run(ctx)
+
+ // THEN: the message should not be sent, and must be set to "inhibited"
+ require.EventuallyWithT(t, func(ct *assert.CollectT) {
+ m, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{
+ Status: database.NotificationMessageStatusInhibited,
+ Limit: 10,
+ })
+ assert.NoError(ct, err)
+ if assert.Equal(ct, len(m), 1) {
+ assert.Equal(ct, m[0].ID.String(), msgID.String())
+ assert.Contains(ct, m[0].StatusReason.String, "disabled by user")
+ }
+ }, testutil.WaitLong, testutil.IntervalFast, "did not find the expected inhibited message")
+}
+
+func TestCustomNotificationMethod(t *testing.T) {
+ t.Parallel()
+
+ // SETUP
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres; it relies on business-logic only implemented in the database")
+ }
+
+ ctx, logger, db := setup(t)
+
+ received := make(chan uuid.UUID, 1)
+
+ // SETUP:
+ // Start mock server to simulate webhook endpoint.
+ mockWebhookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var payload dispatch.WebhookPayload
+ err := json.NewDecoder(r.Body).Decode(&payload)
+ assert.NoError(t, err)
+
+ received <- payload.MsgID
+ close(received)
+
+ w.WriteHeader(http.StatusOK)
+ _, err = w.Write([]byte("noted."))
+ require.NoError(t, err)
+ }))
+ defer mockWebhookSrv.Close()
+
+ // Start mock SMTP server.
+ mockSMTPSrv := smtpmock.New(smtpmock.ConfigurationAttr{
+ LogToStdout: false,
+ LogServerActivity: true,
+ })
+ require.NoError(t, mockSMTPSrv.Start())
+ t.Cleanup(func() {
+ assert.NoError(t, mockSMTPSrv.Stop())
+ })
+
+ endpoint, err := url.Parse(mockWebhookSrv.URL)
+ require.NoError(t, err)
+
+ // GIVEN: a notification template which has a method explicitly set
+ var (
+ template = notifications.TemplateWorkspaceDormant
+ defaultMethod = database.NotificationMethodSmtp
+ customMethod = database.NotificationMethodWebhook
+ )
+ out, err := db.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{
+ ID: template,
+ Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true},
+ })
+ require.NoError(t, err)
+ require.Equal(t, customMethod, out.Method.NotificationMethod)
+
+ // GIVEN: a manager configured with multiple dispatch methods
+ cfg := defaultNotificationsConfig(defaultMethod)
+ cfg.SMTP = codersdk.NotificationsEmailConfig{
+ From: "danny@coder.com",
+ Hello: "localhost",
+ Smarthost: serpent.HostPort{Host: "localhost", Port: fmt.Sprintf("%d", mockSMTPSrv.PortNumber())},
+ }
+ cfg.Webhook = codersdk.NotificationsWebhookConfig{
+ Endpoint: *serpent.URLOf(endpoint),
+ }
+
+ mgr, err := notifications.NewManager(cfg, db, createMetrics(), logger.Named("manager"))
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _ = mgr.Stop(ctx)
+ })
+
+ enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger)
+ require.NoError(t, err)
+
+ // WHEN: a notification of that template is enqueued, it should be delivered with the configured method - not the default.
+ user := createSampleUser(t, db)
+ msgID, err := enq.Enqueue(ctx, user.ID, template, map[string]string{}, "test")
+ require.NoError(t, err)
+
+ // THEN: the notification should be received by the custom dispatch method
+ mgr.Run(ctx)
+
+ receivedMsgID := testutil.RequireRecvCtx(ctx, t, received)
+ require.Equal(t, msgID.String(), receivedMsgID.String())
+
+ // Ensure no messages received by default method (SMTP):
+ msgs := mockSMTPSrv.MessagesAndPurge()
+ require.Len(t, msgs, 0)
+
+ // Enqueue a notification which does not have a custom method set to ensure default works correctly.
+ msgID, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{}, "test")
+ require.NoError(t, err)
+ require.EventuallyWithT(t, func(ct *assert.CollectT) {
+ msgs := mockSMTPSrv.MessagesAndPurge()
+ if assert.Len(ct, msgs, 1) {
+ assert.Contains(ct, msgs[0].MsgRequest(), fmt.Sprintf("Message-Id: %s", msgID))
+ }
+ }, testutil.WaitLong, testutil.IntervalFast)
+}
+
type fakeHandler struct {
mu sync.RWMutex
succeeded, failed []string
diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go
index c39de6168db81..ac7ed8b2d5f4a 100644
--- a/coderd/notifications/notifier.go
+++ b/coderd/notifications/notifier.go
@@ -10,6 +10,7 @@ import (
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
+ "github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/notifications/dispatch"
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
@@ -33,12 +34,11 @@ type notifier struct {
quit chan any
done chan any
- method database.NotificationMethod
handlers map[database.NotificationMethod]Handler
metrics *Metrics
}
-func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler, method database.NotificationMethod, metrics *Metrics) *notifier {
+func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler, metrics *Metrics) *notifier {
return ¬ifier{
id: id,
cfg: cfg,
@@ -48,7 +48,6 @@ func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger
tick: time.NewTicker(cfg.FetchInterval.Value()),
store: db,
handlers: hr,
- method: method,
metrics: metrics,
}
}
@@ -144,6 +143,12 @@ func (n *notifier) process(ctx context.Context, success chan<- dispatchResult, f
var eg errgroup.Group
for _, msg := range msgs {
+ // If a notification template has been disabled by the user after a notification was enqueued, mark it as inhibited
+ if msg.Disabled {
+ failure <- n.newInhibitedDispatch(msg)
+ continue
+ }
+
// A message failing to be prepared correctly should not affect other messages.
deliverFn, err := n.prepare(ctx, msg)
if err != nil {
@@ -234,17 +239,17 @@ func (n *notifier) deliver(ctx context.Context, msg database.AcquireNotification
logger := n.log.With(slog.F("msg_id", msg.ID), slog.F("method", msg.Method), slog.F("attempt", msg.AttemptCount+1))
if msg.AttemptCount > 0 {
- n.metrics.RetryCount.WithLabelValues(string(n.method), msg.TemplateID.String()).Inc()
+ n.metrics.RetryCount.WithLabelValues(string(msg.Method), msg.TemplateID.String()).Inc()
}
- n.metrics.InflightDispatches.WithLabelValues(string(n.method), msg.TemplateID.String()).Inc()
- n.metrics.QueuedSeconds.WithLabelValues(string(n.method)).Observe(msg.QueuedSeconds)
+ n.metrics.InflightDispatches.WithLabelValues(string(msg.Method), msg.TemplateID.String()).Inc()
+ n.metrics.QueuedSeconds.WithLabelValues(string(msg.Method)).Observe(msg.QueuedSeconds)
start := time.Now()
retryable, err := deliver(ctx, msg.ID)
- n.metrics.DispatcherSendSeconds.WithLabelValues(string(n.method)).Observe(time.Since(start).Seconds())
- n.metrics.InflightDispatches.WithLabelValues(string(n.method), msg.TemplateID.String()).Dec()
+ n.metrics.DispatcherSendSeconds.WithLabelValues(string(msg.Method)).Observe(time.Since(start).Seconds())
+ n.metrics.InflightDispatches.WithLabelValues(string(msg.Method), msg.TemplateID.String()).Dec()
if err != nil {
// Don't try to accumulate message responses if the context has been canceled.
@@ -281,12 +286,12 @@ func (n *notifier) deliver(ctx context.Context, msg database.AcquireNotification
}
func (n *notifier) newSuccessfulDispatch(msg database.AcquireNotificationMessagesRow) dispatchResult {
- n.metrics.DispatchAttempts.WithLabelValues(string(n.method), msg.TemplateID.String(), ResultSuccess).Inc()
+ n.metrics.DispatchAttempts.WithLabelValues(string(msg.Method), msg.TemplateID.String(), ResultSuccess).Inc()
return dispatchResult{
notifier: n.id,
msg: msg.ID,
- ts: time.Now(),
+ ts: dbtime.Now(),
}
}
@@ -301,17 +306,27 @@ func (n *notifier) newFailedDispatch(msg database.AcquireNotificationMessagesRow
result = ResultPermFail
}
- n.metrics.DispatchAttempts.WithLabelValues(string(n.method), msg.TemplateID.String(), result).Inc()
+ n.metrics.DispatchAttempts.WithLabelValues(string(msg.Method), msg.TemplateID.String(), result).Inc()
return dispatchResult{
notifier: n.id,
msg: msg.ID,
- ts: time.Now(),
+ ts: dbtime.Now(),
err: err,
retryable: retryable,
}
}
+func (n *notifier) newInhibitedDispatch(msg database.AcquireNotificationMessagesRow) dispatchResult {
+ return dispatchResult{
+ notifier: n.id,
+ msg: msg.ID,
+ ts: dbtime.Now(),
+ retryable: false,
+ inhibited: true,
+ }
+}
+
// stop stops the notifier from processing any new notifications.
// This is a graceful stop, so any in-flight notifications will be completed before the notifier stops.
// Once a notifier has stopped, it cannot be restarted.
diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go
index 7690154a0db80..6cea84af11cc2 100644
--- a/coderd/notifications_test.go
+++ b/coderd/notifications_test.go
@@ -5,19 +5,34 @@ import (
"testing"
"github.com/stretchr/testify/require"
+ "golang.org/x/exp/slices"
+
+ "github.com/coder/serpent"
"github.com/coder/coder/v2/coderd/coderdtest"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
+func createOpts(t *testing.T) *coderdtest.Options {
+ t.Helper()
+
+ dt := coderdtest.DeploymentValues(t)
+ dt.Experiments = []string{string(codersdk.ExperimentNotifications)}
+ return &coderdtest.Options{
+ DeploymentValues: dt,
+ }
+}
+
func TestUpdateNotificationsSettings(t *testing.T) {
t.Parallel()
t.Run("Permissions denied", func(t *testing.T) {
t.Parallel()
- api := coderdtest.New(t, nil)
+ api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
@@ -41,7 +56,7 @@ func TestUpdateNotificationsSettings(t *testing.T) {
t.Run("Settings modified", func(t *testing.T) {
t.Parallel()
- client := coderdtest.New(t, nil)
+ client := coderdtest.New(t, createOpts(t))
_ = coderdtest.CreateFirstUser(t, client)
// given
@@ -65,7 +80,7 @@ func TestUpdateNotificationsSettings(t *testing.T) {
t.Parallel()
// Empty state: notifications Settings are undefined now (default).
- client := coderdtest.New(t, nil)
+ client := coderdtest.New(t, createOpts(t))
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitShort)
@@ -93,3 +108,213 @@ func TestUpdateNotificationsSettings(t *testing.T) {
require.Equal(t, expected.NotifierPaused, actual.NotifierPaused)
})
}
+
+func TestNotificationPreferences(t *testing.T) {
+ t.Parallel()
+
+ t.Run("Initial state", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ api := coderdtest.New(t, createOpts(t))
+ firstUser := coderdtest.CreateFirstUser(t, api)
+
+ // Given: a member in its initial state.
+ memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
+
+ // When: calling the API.
+ prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID)
+ require.NoError(t, err)
+
+ // Then: no preferences will be returned.
+ require.Len(t, prefs, 0)
+ })
+
+ t.Run("Insufficient permissions", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ api := coderdtest.New(t, createOpts(t))
+ firstUser := coderdtest.CreateFirstUser(t, api)
+
+ // Given: 2 members.
+ _, member1 := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
+ member2Client, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
+
+ // When: attempting to retrieve the preferences of another member.
+ _, err := member2Client.GetUserNotificationPreferences(ctx, member1.ID)
+
+ // Then: the API should reject the request.
+ var sdkError *codersdk.Error
+ require.Error(t, err)
+ require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
+ // NOTE: ExtractUserParam gets in the way here, and returns a 400 Bad Request instead of a 403 Forbidden.
+ // This is not ideal, and we should probably change this behavior.
+ require.Equal(t, http.StatusBadRequest, sdkError.StatusCode())
+ })
+
+ t.Run("Admin may read any users' preferences", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ api := coderdtest.New(t, createOpts(t))
+ firstUser := coderdtest.CreateFirstUser(t, api)
+
+ // Given: a member.
+ _, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
+
+ // When: attempting to retrieve the preferences of another member as an admin.
+ prefs, err := api.GetUserNotificationPreferences(ctx, member.ID)
+
+ // Then: the API should not reject the request.
+ require.NoError(t, err)
+ require.Len(t, prefs, 0)
+ })
+
+ t.Run("Admin may update any users' preferences", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ api := coderdtest.New(t, createOpts(t))
+ firstUser := coderdtest.CreateFirstUser(t, api)
+
+ // Given: a member.
+ memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
+
+ // When: attempting to modify and subsequently retrieve the preferences of another member as an admin.
+ prefs, err := api.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
+ TemplateDisabledMap: map[string]bool{
+ notifications.TemplateWorkspaceMarkedForDeletion.String(): true,
+ },
+ })
+
+ // Then: the request should succeed and the user should be able to query their own preferences to see the same result.
+ require.NoError(t, err)
+ require.Len(t, prefs, 1)
+
+ memberPrefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID)
+ require.NoError(t, err)
+ require.Len(t, memberPrefs, 1)
+ require.Equal(t, prefs[0].NotificationTemplateID, memberPrefs[0].NotificationTemplateID)
+ require.Equal(t, prefs[0].Disabled, memberPrefs[0].Disabled)
+ })
+
+ t.Run("Add preferences", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ api := coderdtest.New(t, createOpts(t))
+ firstUser := coderdtest.CreateFirstUser(t, api)
+
+ // Given: a member with no preferences.
+ memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
+ prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID)
+ require.NoError(t, err)
+ require.Len(t, prefs, 0)
+
+ // When: attempting to add new preferences.
+ template := notifications.TemplateWorkspaceDeleted
+ prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
+ TemplateDisabledMap: map[string]bool{
+ template.String(): true,
+ },
+ })
+
+ // Then: the returning preferences should be set as expected.
+ require.NoError(t, err)
+ require.Len(t, prefs, 1)
+ require.Equal(t, prefs[0].NotificationTemplateID, template)
+ require.True(t, prefs[0].Disabled)
+ })
+
+ t.Run("Modify preferences", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ api := coderdtest.New(t, createOpts(t))
+ firstUser := coderdtest.CreateFirstUser(t, api)
+
+ // Given: a member with preferences.
+ memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
+ prefs, err := memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
+ TemplateDisabledMap: map[string]bool{
+ notifications.TemplateWorkspaceDeleted.String(): true,
+ notifications.TemplateWorkspaceDormant.String(): true,
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, prefs, 2)
+
+ // When: attempting to modify their preferences.
+ prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
+ TemplateDisabledMap: map[string]bool{
+ notifications.TemplateWorkspaceDeleted.String(): true,
+ notifications.TemplateWorkspaceDormant.String(): false, // <--- this one was changed
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, prefs, 2)
+
+ // Then: the modified preferences should be set as expected.
+ var found bool
+ for _, p := range prefs {
+ switch p.NotificationTemplateID {
+ case notifications.TemplateWorkspaceDormant:
+ found = true
+ require.False(t, p.Disabled)
+ case notifications.TemplateWorkspaceDeleted:
+ require.True(t, p.Disabled)
+ }
+ }
+ require.True(t, found, "dormant notification preference was not found")
+ })
+}
+
+func TestNotificationDispatchMethods(t *testing.T) {
+ t.Parallel()
+
+ defaultOpts := createOpts(t)
+ webhookOpts := createOpts(t)
+ webhookOpts.DeploymentValues.Notifications.Method = serpent.String(database.NotificationMethodWebhook)
+
+ tests := []struct {
+ name string
+ opts *coderdtest.Options
+ expectedDefault string
+ }{
+ {
+ name: "default",
+ opts: defaultOpts,
+ expectedDefault: string(database.NotificationMethodSmtp),
+ },
+ {
+ name: "non-default",
+ opts: webhookOpts,
+ expectedDefault: string(database.NotificationMethodWebhook),
+ },
+ }
+
+ var allMethods []string
+ for _, nm := range database.AllNotificationMethodValues() {
+ allMethods = append(allMethods, string(nm))
+ }
+ slices.Sort(allMethods)
+
+ // nolint:paralleltest // Not since Go v1.22.
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+ api := coderdtest.New(t, tc.opts)
+ _ = coderdtest.CreateFirstUser(t, api)
+
+ resp, err := api.GetNotificationDispatchMethods(ctx)
+ require.NoError(t, err)
+
+ slices.Sort(resp.AvailableNotificationMethods)
+ require.EqualValues(t, resp.AvailableNotificationMethods, allMethods)
+ require.Equal(t, tc.expectedDefault, resp.DefaultNotificationMethod)
+ })
+ }
+}
diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go
index bc2846da49564..7645f65c5c502 100644
--- a/coderd/rbac/object_gen.go
+++ b/coderd/rbac/object_gen.go
@@ -102,6 +102,22 @@ var (
Type: "license",
}
+ // ResourceNotificationPreference
+ // Valid Actions
+ // - "ActionRead" :: read notification preferences
+ // - "ActionUpdate" :: update notification preferences
+ ResourceNotificationPreference = Object{
+ Type: "notification_preference",
+ }
+
+ // ResourceNotificationTemplate
+ // Valid Actions
+ // - "ActionRead" :: read notification templates
+ // - "ActionUpdate" :: update notification templates
+ ResourceNotificationTemplate = Object{
+ Type: "notification_template",
+ }
+
// ResourceOauth2App
// Valid Actions
// - "ActionCreate" :: make an OAuth2 app.
@@ -272,6 +288,8 @@ func AllResources() []Objecter {
ResourceFile,
ResourceGroup,
ResourceLicense,
+ ResourceNotificationPreference,
+ ResourceNotificationTemplate,
ResourceOauth2App,
ResourceOauth2AppCodeToken,
ResourceOauth2AppSecret,
diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go
index 2390c9e30c785..54dcbe358007b 100644
--- a/coderd/rbac/policy/policy.go
+++ b/coderd/rbac/policy/policy.go
@@ -255,4 +255,16 @@ var RBACPermissions = map[string]PermissionDefinition{
ActionDelete: actDef(""),
},
},
+ "notification_template": {
+ Actions: map[Action]ActionDefinition{
+ ActionRead: actDef("read notification templates"),
+ ActionUpdate: actDef("update notification templates"),
+ },
+ },
+ "notification_preference": {
+ Actions: map[Action]ActionDefinition{
+ ActionRead: actDef("read notification preferences"),
+ ActionUpdate: actDef("update notification preferences"),
+ },
+ },
}
diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go
index 225e5eb9d311e..9d71629d95d9f 100644
--- a/coderd/rbac/roles_test.go
+++ b/coderd/rbac/roles_test.go
@@ -590,6 +590,54 @@ func TestRolePermissions(t *testing.T) {
false: {},
},
},
+ {
+ // Any owner/admin across may access any users' preferences
+ // Members may not access other members' preferences
+ Name: "NotificationPreferencesOwn",
+ Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
+ Resource: rbac.ResourceNotificationPreference.WithOwner(currentUser.String()),
+ AuthorizeMap: map[bool][]hasAuthSubjects{
+ true: {memberMe, orgMemberMe, owner},
+ false: {
+ userAdmin, orgUserAdmin, templateAdmin,
+ orgAuditor, orgTemplateAdmin,
+ otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
+ orgAdmin, otherOrgAdmin,
+ },
+ },
+ },
+ {
+ // Any owner/admin may access notification templates
+ Name: "NotificationTemplates",
+ Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
+ Resource: rbac.ResourceNotificationTemplate,
+ AuthorizeMap: map[bool][]hasAuthSubjects{
+ true: {owner},
+ false: {
+ memberMe, orgMemberMe, userAdmin, orgUserAdmin, templateAdmin,
+ orgAuditor, orgTemplateAdmin,
+ otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
+ orgAdmin, otherOrgAdmin,
+ },
+ },
+ },
+ {
+ // Notification preferences are currently not organization-scoped
+ // Any owner/admin may access any users' preferences
+ // Members may not access other members' preferences
+ Name: "NotificationPreferencesOtherUser",
+ Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
+ Resource: rbac.ResourceNotificationPreference.WithOwner(uuid.NewString()), // some other user
+ AuthorizeMap: map[bool][]hasAuthSubjects{
+ true: {owner},
+ false: {
+ memberMe, templateAdmin, orgUserAdmin, userAdmin,
+ orgAdmin, orgAuditor, orgTemplateAdmin,
+ otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
+ otherOrgAdmin, orgMemberMe,
+ },
+ },
+ },
// AnyOrganization tests
{
Name: "CreateOrgMember",
diff --git a/codersdk/audit.go b/codersdk/audit.go
index 33b4714f03df6..7d83c8e238ce0 100644
--- a/codersdk/audit.go
+++ b/codersdk/audit.go
@@ -33,6 +33,7 @@ const (
ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret"
ResourceTypeCustomRole ResourceType = "custom_role"
ResourceTypeOrganizationMember = "organization_member"
+ ResourceTypeNotificationTemplate = "notification_template"
)
func (r ResourceType) FriendlyString() string {
@@ -75,6 +76,8 @@ func (r ResourceType) FriendlyString() string {
return "custom role"
case ResourceTypeOrganizationMember:
return "organization member"
+ case ResourceTypeNotificationTemplate:
+ return "notification template"
default:
return "unknown"
}
diff --git a/codersdk/notifications.go b/codersdk/notifications.go
index 58829eed57891..92870b4dd2b95 100644
--- a/codersdk/notifications.go
+++ b/codersdk/notifications.go
@@ -3,13 +3,43 @@ package codersdk
import (
"context"
"encoding/json"
+ "fmt"
+ "io"
"net/http"
+ "time"
+
+ "github.com/google/uuid"
+ "golang.org/x/xerrors"
)
type NotificationsSettings struct {
NotifierPaused bool `json:"notifier_paused"`
}
+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"`
+}
+
+type NotificationMethodsResponse struct {
+ AvailableNotificationMethods []string `json:"available"`
+ DefaultNotificationMethod string `json:"default"`
+}
+
+type NotificationPreference struct {
+ NotificationTemplateID uuid.UUID `json:"id" format:"uuid"`
+ Disabled bool `json:"disabled"`
+ UpdatedAt time.Time `json:"updated_at" format:"date-time"`
+}
+
+// GetNotificationsSettings retrieves the notifications settings, which currently just describes whether all
+// notifications are paused from sending.
func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSettings, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/settings", nil)
if err != nil {
@@ -23,6 +53,8 @@ func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSet
return settings, json.NewDecoder(res.Body).Decode(&settings)
}
+// PutNotificationsSettings modifies the notifications settings, which currently just controls whether all
+// notifications are paused from sending.
func (c *Client) PutNotificationsSettings(ctx context.Context, settings NotificationsSettings) error {
res, err := c.Request(ctx, http.MethodPut, "/api/v2/notifications/settings", settings)
if err != nil {
@@ -38,3 +70,132 @@ func (c *Client) PutNotificationsSettings(ctx context.Context, settings Notifica
}
return nil
}
+
+// UpdateNotificationTemplateMethod modifies a notification template to use a specific notification method, overriding
+// the method set in the deployment configuration.
+func (c *Client) UpdateNotificationTemplateMethod(ctx context.Context, notificationTemplateID uuid.UUID, method string) error {
+ res, err := c.Request(ctx, http.MethodPut,
+ fmt.Sprintf("/api/v2/notifications/templates/%s/method", notificationTemplateID),
+ UpdateNotificationTemplateMethod{Method: method},
+ )
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode == http.StatusNotModified {
+ return nil
+ }
+ if res.StatusCode != http.StatusOK {
+ return ReadBodyAsError(res)
+ }
+ return nil
+}
+
+// GetSystemNotificationTemplates retrieves all notification templates pertaining to internal system events.
+func (c *Client) GetSystemNotificationTemplates(ctx context.Context) ([]NotificationTemplate, error) {
+ res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/templates/system", nil)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return nil, ReadBodyAsError(res)
+ }
+
+ var templates []NotificationTemplate
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, xerrors.Errorf("read response body: %w", err)
+ }
+
+ if err := json.Unmarshal(body, &templates); err != nil {
+ return nil, xerrors.Errorf("unmarshal response body: %w", err)
+ }
+
+ return templates, nil
+}
+
+// GetUserNotificationPreferences retrieves notification preferences for a given user.
+func (c *Client) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) {
+ res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), nil)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return nil, ReadBodyAsError(res)
+ }
+
+ var prefs []NotificationPreference
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, xerrors.Errorf("read response body: %w", err)
+ }
+
+ if err := json.Unmarshal(body, &prefs); err != nil {
+ return nil, xerrors.Errorf("unmarshal response body: %w", err)
+ }
+
+ return prefs, nil
+}
+
+// UpdateUserNotificationPreferences updates notification preferences for a given user.
+func (c *Client) UpdateUserNotificationPreferences(ctx context.Context, userID uuid.UUID, req UpdateUserNotificationPreferences) ([]NotificationPreference, error) {
+ res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return nil, ReadBodyAsError(res)
+ }
+
+ var prefs []NotificationPreference
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, xerrors.Errorf("read response body: %w", err)
+ }
+
+ if err := json.Unmarshal(body, &prefs); err != nil {
+ return nil, xerrors.Errorf("unmarshal response body: %w", err)
+ }
+
+ return prefs, nil
+}
+
+// GetNotificationDispatchMethods the available and default notification dispatch methods.
+func (c *Client) GetNotificationDispatchMethods(ctx context.Context) (NotificationMethodsResponse, error) {
+ res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/dispatch-methods", nil)
+ if err != nil {
+ return NotificationMethodsResponse{}, err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return NotificationMethodsResponse{}, ReadBodyAsError(res)
+ }
+
+ var resp NotificationMethodsResponse
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ return NotificationMethodsResponse{}, xerrors.Errorf("read response body: %w", err)
+ }
+
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return NotificationMethodsResponse{}, xerrors.Errorf("unmarshal response body: %w", err)
+ }
+
+ return resp, nil
+}
+
+type UpdateNotificationTemplateMethod struct {
+ Method string `json:"method,omitempty" example:"webhook"`
+}
+
+type UpdateUserNotificationPreferences struct {
+ TemplateDisabledMap map[string]bool `json:"template_disabled_map"`
+}
diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go
index 573fea66b8c80..788cab912643b 100644
--- a/codersdk/rbacresources_gen.go
+++ b/codersdk/rbacresources_gen.go
@@ -4,32 +4,34 @@ package codersdk
type RBACResource string
const (
- ResourceWildcard RBACResource = "*"
- ResourceApiKey RBACResource = "api_key"
- ResourceAssignOrgRole RBACResource = "assign_org_role"
- ResourceAssignRole RBACResource = "assign_role"
- ResourceAuditLog RBACResource = "audit_log"
- ResourceDebugInfo RBACResource = "debug_info"
- ResourceDeploymentConfig RBACResource = "deployment_config"
- ResourceDeploymentStats RBACResource = "deployment_stats"
- ResourceFile RBACResource = "file"
- ResourceGroup RBACResource = "group"
- ResourceLicense RBACResource = "license"
- ResourceOauth2App RBACResource = "oauth2_app"
- ResourceOauth2AppCodeToken RBACResource = "oauth2_app_code_token"
- ResourceOauth2AppSecret RBACResource = "oauth2_app_secret"
- ResourceOrganization RBACResource = "organization"
- ResourceOrganizationMember RBACResource = "organization_member"
- ResourceProvisionerDaemon RBACResource = "provisioner_daemon"
- ResourceProvisionerKeys RBACResource = "provisioner_keys"
- ResourceReplicas RBACResource = "replicas"
- ResourceSystem RBACResource = "system"
- ResourceTailnetCoordinator RBACResource = "tailnet_coordinator"
- ResourceTemplate RBACResource = "template"
- ResourceUser RBACResource = "user"
- ResourceWorkspace RBACResource = "workspace"
- ResourceWorkspaceDormant RBACResource = "workspace_dormant"
- ResourceWorkspaceProxy RBACResource = "workspace_proxy"
+ ResourceWildcard RBACResource = "*"
+ ResourceApiKey RBACResource = "api_key"
+ ResourceAssignOrgRole RBACResource = "assign_org_role"
+ ResourceAssignRole RBACResource = "assign_role"
+ ResourceAuditLog RBACResource = "audit_log"
+ ResourceDebugInfo RBACResource = "debug_info"
+ ResourceDeploymentConfig RBACResource = "deployment_config"
+ ResourceDeploymentStats RBACResource = "deployment_stats"
+ ResourceFile RBACResource = "file"
+ ResourceGroup RBACResource = "group"
+ ResourceLicense RBACResource = "license"
+ ResourceNotificationPreference RBACResource = "notification_preference"
+ ResourceNotificationTemplate RBACResource = "notification_template"
+ ResourceOauth2App RBACResource = "oauth2_app"
+ ResourceOauth2AppCodeToken RBACResource = "oauth2_app_code_token"
+ ResourceOauth2AppSecret RBACResource = "oauth2_app_secret"
+ ResourceOrganization RBACResource = "organization"
+ ResourceOrganizationMember RBACResource = "organization_member"
+ ResourceProvisionerDaemon RBACResource = "provisioner_daemon"
+ ResourceProvisionerKeys RBACResource = "provisioner_keys"
+ ResourceReplicas RBACResource = "replicas"
+ ResourceSystem RBACResource = "system"
+ ResourceTailnetCoordinator RBACResource = "tailnet_coordinator"
+ ResourceTemplate RBACResource = "template"
+ ResourceUser RBACResource = "user"
+ ResourceWorkspace RBACResource = "workspace"
+ ResourceWorkspaceDormant RBACResource = "workspace_dormant"
+ ResourceWorkspaceProxy RBACResource = "workspace_proxy"
)
type RBACAction string
@@ -53,30 +55,32 @@ const (
// RBACResourceActions is the mapping of resources to which actions are valid for
// said resource type.
var RBACResourceActions = map[RBACResource][]RBACAction{
- ResourceWildcard: {},
- ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
- ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead},
- ResourceAssignRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead},
- ResourceAuditLog: {ActionCreate, ActionRead},
- ResourceDebugInfo: {ActionRead},
- ResourceDeploymentConfig: {ActionRead, ActionUpdate},
- ResourceDeploymentStats: {ActionRead},
- ResourceFile: {ActionCreate, ActionRead},
- ResourceGroup: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
- ResourceLicense: {ActionCreate, ActionDelete, ActionRead},
- ResourceOauth2App: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
- ResourceOauth2AppCodeToken: {ActionCreate, ActionDelete, ActionRead},
- ResourceOauth2AppSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
- ResourceOrganization: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
- ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
- ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
- ResourceProvisionerKeys: {ActionCreate, ActionDelete, ActionRead},
- ResourceReplicas: {ActionRead},
- ResourceSystem: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
- ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
- ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights},
- ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal},
- ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate},
- ResourceWorkspaceDormant: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate},
- ResourceWorkspaceProxy: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceWildcard: {},
+ ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead},
+ ResourceAssignRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead},
+ ResourceAuditLog: {ActionCreate, ActionRead},
+ ResourceDebugInfo: {ActionRead},
+ ResourceDeploymentConfig: {ActionRead, ActionUpdate},
+ ResourceDeploymentStats: {ActionRead},
+ ResourceFile: {ActionCreate, ActionRead},
+ ResourceGroup: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceLicense: {ActionCreate, ActionDelete, ActionRead},
+ ResourceNotificationPreference: {ActionRead, ActionUpdate},
+ ResourceNotificationTemplate: {ActionRead, ActionUpdate},
+ ResourceOauth2App: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceOauth2AppCodeToken: {ActionCreate, ActionDelete, ActionRead},
+ ResourceOauth2AppSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceOrganization: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceProvisionerKeys: {ActionCreate, ActionDelete, ActionRead},
+ ResourceReplicas: {ActionRead},
+ ResourceSystem: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights},
+ ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal},
+ ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate},
+ ResourceWorkspaceDormant: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate},
+ ResourceWorkspaceProxy: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
}
diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md
index a6f8e4e5117da..564a4f8594a0e 100644
--- a/docs/admin/audit-logs.md
+++ b/docs/admin/audit-logs.md
@@ -18,6 +18,7 @@ We track the following resources:
| GitSSHKey
create |
Field | Tracked |
---|
created_at | false |
private_key | true |
public_key | true |
updated_at | false |
user_id | true |
|
| HealthSettings
| Field | Tracked |
---|
dismissed_healthchecks | true |
id | false |
|
| License
create, delete | Field | Tracked |
---|
exp | true |
id | false |
jwt | false |
uploaded_at | true |
uuid | true |
|
+| NotificationTemplate
| Field | Tracked |
---|
actions | true |
body_template | true |
group | true |
id | false |
kind | true |
method | true |
name | true |
title_template | true |
|
| NotificationsSettings
| Field | Tracked |
---|
id | false |
notifier_paused | true |
|
| OAuth2ProviderApp
| Field | Tracked |
---|
callback_url | true |
created_at | false |
icon | true |
id | false |
name | true |
updated_at | false |
|
| OAuth2ProviderAppSecret
| Field | Tracked |
---|
app_id | false |
created_at | false |
display_secret | false |
hashed_secret | false |
id | false |
last_used_at | false |
secret_prefix | false |
|
diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md
index dec875eebaac3..be30b790d4aef 100644
--- a/docs/api/enterprise.md
+++ b/docs/api/enterprise.md
@@ -537,6 +537,33 @@ curl -X DELETE http://coder-server:8080/api/v2/licenses/{id} \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+## Update notification template dispatch method
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X PUT http://coder-server:8080/api/v2/notifications/templates/{notification_template}/method \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`PUT /notifications/templates/{notification_template}/method`
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+| ----------------------- | ---- | ------ | -------- | -------------------------- |
+| `notification_template` | path | string | true | Notification template UUID |
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | --------------------------------------------------------------- | ------------ | ------ |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Success | |
+| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not modified | |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
## Get OAuth2 applications.
### Code samples
diff --git a/docs/api/general.md b/docs/api/general.md
index e913a4c804cd6..52cfd25f4c46c 100644
--- a/docs/api/general.md
+++ b/docs/api/general.md
@@ -667,84 +667,6 @@ Status Code **200**
To perform this operation, you must be authenticated. [Learn more](authentication.md).
-## Get notifications settings
-
-### Code samples
-
-```shell
-# Example request using curl
-curl -X GET http://coder-server:8080/api/v2/notifications/settings \
- -H 'Accept: application/json' \
- -H 'Coder-Session-Token: API_KEY'
-```
-
-`GET /notifications/settings`
-
-### Example responses
-
-> 200 Response
-
-```json
-{
- "notifier_paused": true
-}
-```
-
-### Responses
-
-| Status | Meaning | Description | Schema |
-| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- |
-| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) |
-
-To perform this operation, you must be authenticated. [Learn more](authentication.md).
-
-## Update notifications settings
-
-### Code samples
-
-```shell
-# Example request using curl
-curl -X PUT http://coder-server:8080/api/v2/notifications/settings \
- -H 'Content-Type: application/json' \
- -H 'Accept: application/json' \
- -H 'Coder-Session-Token: API_KEY'
-```
-
-`PUT /notifications/settings`
-
-> Body parameter
-
-```json
-{
- "notifier_paused": true
-}
-```
-
-### Parameters
-
-| Name | In | Type | Required | Description |
-| ------ | ---- | -------------------------------------------------------------------------- | -------- | ------------------------------ |
-| `body` | body | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | true | Notifications settings request |
-
-### Example responses
-
-> 200 Response
-
-```json
-{
- "notifier_paused": true
-}
-```
-
-### Responses
-
-| Status | Meaning | Description | Schema |
-| ------ | --------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- |
-| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) |
-| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not Modified | |
-
-To perform this operation, you must be authenticated. [Learn more](authentication.md).
-
## Update check
### Code samples
diff --git a/docs/api/members.md b/docs/api/members.md
index 1ecf490738f00..63bf06b9b0f8c 100644
--- a/docs/api/members.md
+++ b/docs/api/members.md
@@ -164,47 +164,49 @@ Status Code **200**
#### Enumerated Values
-| Property | Value |
-| --------------- | ----------------------- |
-| `action` | `application_connect` |
-| `action` | `assign` |
-| `action` | `create` |
-| `action` | `delete` |
-| `action` | `read` |
-| `action` | `read_personal` |
-| `action` | `ssh` |
-| `action` | `update` |
-| `action` | `update_personal` |
-| `action` | `use` |
-| `action` | `view_insights` |
-| `action` | `start` |
-| `action` | `stop` |
-| `resource_type` | `*` |
-| `resource_type` | `api_key` |
-| `resource_type` | `assign_org_role` |
-| `resource_type` | `assign_role` |
-| `resource_type` | `audit_log` |
-| `resource_type` | `debug_info` |
-| `resource_type` | `deployment_config` |
-| `resource_type` | `deployment_stats` |
-| `resource_type` | `file` |
-| `resource_type` | `group` |
-| `resource_type` | `license` |
-| `resource_type` | `oauth2_app` |
-| `resource_type` | `oauth2_app_code_token` |
-| `resource_type` | `oauth2_app_secret` |
-| `resource_type` | `organization` |
-| `resource_type` | `organization_member` |
-| `resource_type` | `provisioner_daemon` |
-| `resource_type` | `provisioner_keys` |
-| `resource_type` | `replicas` |
-| `resource_type` | `system` |
-| `resource_type` | `tailnet_coordinator` |
-| `resource_type` | `template` |
-| `resource_type` | `user` |
-| `resource_type` | `workspace` |
-| `resource_type` | `workspace_dormant` |
-| `resource_type` | `workspace_proxy` |
+| Property | Value |
+| --------------- | ------------------------- |
+| `action` | `application_connect` |
+| `action` | `assign` |
+| `action` | `create` |
+| `action` | `delete` |
+| `action` | `read` |
+| `action` | `read_personal` |
+| `action` | `ssh` |
+| `action` | `update` |
+| `action` | `update_personal` |
+| `action` | `use` |
+| `action` | `view_insights` |
+| `action` | `start` |
+| `action` | `stop` |
+| `resource_type` | `*` |
+| `resource_type` | `api_key` |
+| `resource_type` | `assign_org_role` |
+| `resource_type` | `assign_role` |
+| `resource_type` | `audit_log` |
+| `resource_type` | `debug_info` |
+| `resource_type` | `deployment_config` |
+| `resource_type` | `deployment_stats` |
+| `resource_type` | `file` |
+| `resource_type` | `group` |
+| `resource_type` | `license` |
+| `resource_type` | `notification_preference` |
+| `resource_type` | `notification_template` |
+| `resource_type` | `oauth2_app` |
+| `resource_type` | `oauth2_app_code_token` |
+| `resource_type` | `oauth2_app_secret` |
+| `resource_type` | `organization` |
+| `resource_type` | `organization_member` |
+| `resource_type` | `provisioner_daemon` |
+| `resource_type` | `provisioner_keys` |
+| `resource_type` | `replicas` |
+| `resource_type` | `system` |
+| `resource_type` | `tailnet_coordinator` |
+| `resource_type` | `template` |
+| `resource_type` | `user` |
+| `resource_type` | `workspace` |
+| `resource_type` | `workspace_dormant` |
+| `resource_type` | `workspace_proxy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -287,47 +289,49 @@ Status Code **200**
#### Enumerated Values
-| Property | Value |
-| --------------- | ----------------------- |
-| `action` | `application_connect` |
-| `action` | `assign` |
-| `action` | `create` |
-| `action` | `delete` |
-| `action` | `read` |
-| `action` | `read_personal` |
-| `action` | `ssh` |
-| `action` | `update` |
-| `action` | `update_personal` |
-| `action` | `use` |
-| `action` | `view_insights` |
-| `action` | `start` |
-| `action` | `stop` |
-| `resource_type` | `*` |
-| `resource_type` | `api_key` |
-| `resource_type` | `assign_org_role` |
-| `resource_type` | `assign_role` |
-| `resource_type` | `audit_log` |
-| `resource_type` | `debug_info` |
-| `resource_type` | `deployment_config` |
-| `resource_type` | `deployment_stats` |
-| `resource_type` | `file` |
-| `resource_type` | `group` |
-| `resource_type` | `license` |
-| `resource_type` | `oauth2_app` |
-| `resource_type` | `oauth2_app_code_token` |
-| `resource_type` | `oauth2_app_secret` |
-| `resource_type` | `organization` |
-| `resource_type` | `organization_member` |
-| `resource_type` | `provisioner_daemon` |
-| `resource_type` | `provisioner_keys` |
-| `resource_type` | `replicas` |
-| `resource_type` | `system` |
-| `resource_type` | `tailnet_coordinator` |
-| `resource_type` | `template` |
-| `resource_type` | `user` |
-| `resource_type` | `workspace` |
-| `resource_type` | `workspace_dormant` |
-| `resource_type` | `workspace_proxy` |
+| Property | Value |
+| --------------- | ------------------------- |
+| `action` | `application_connect` |
+| `action` | `assign` |
+| `action` | `create` |
+| `action` | `delete` |
+| `action` | `read` |
+| `action` | `read_personal` |
+| `action` | `ssh` |
+| `action` | `update` |
+| `action` | `update_personal` |
+| `action` | `use` |
+| `action` | `view_insights` |
+| `action` | `start` |
+| `action` | `stop` |
+| `resource_type` | `*` |
+| `resource_type` | `api_key` |
+| `resource_type` | `assign_org_role` |
+| `resource_type` | `assign_role` |
+| `resource_type` | `audit_log` |
+| `resource_type` | `debug_info` |
+| `resource_type` | `deployment_config` |
+| `resource_type` | `deployment_stats` |
+| `resource_type` | `file` |
+| `resource_type` | `group` |
+| `resource_type` | `license` |
+| `resource_type` | `notification_preference` |
+| `resource_type` | `notification_template` |
+| `resource_type` | `oauth2_app` |
+| `resource_type` | `oauth2_app_code_token` |
+| `resource_type` | `oauth2_app_secret` |
+| `resource_type` | `organization` |
+| `resource_type` | `organization_member` |
+| `resource_type` | `provisioner_daemon` |
+| `resource_type` | `provisioner_keys` |
+| `resource_type` | `replicas` |
+| `resource_type` | `system` |
+| `resource_type` | `tailnet_coordinator` |
+| `resource_type` | `template` |
+| `resource_type` | `user` |
+| `resource_type` | `workspace` |
+| `resource_type` | `workspace_dormant` |
+| `resource_type` | `workspace_proxy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -541,46 +545,48 @@ Status Code **200**
#### Enumerated Values
-| Property | Value |
-| --------------- | ----------------------- |
-| `action` | `application_connect` |
-| `action` | `assign` |
-| `action` | `create` |
-| `action` | `delete` |
-| `action` | `read` |
-| `action` | `read_personal` |
-| `action` | `ssh` |
-| `action` | `update` |
-| `action` | `update_personal` |
-| `action` | `use` |
-| `action` | `view_insights` |
-| `action` | `start` |
-| `action` | `stop` |
-| `resource_type` | `*` |
-| `resource_type` | `api_key` |
-| `resource_type` | `assign_org_role` |
-| `resource_type` | `assign_role` |
-| `resource_type` | `audit_log` |
-| `resource_type` | `debug_info` |
-| `resource_type` | `deployment_config` |
-| `resource_type` | `deployment_stats` |
-| `resource_type` | `file` |
-| `resource_type` | `group` |
-| `resource_type` | `license` |
-| `resource_type` | `oauth2_app` |
-| `resource_type` | `oauth2_app_code_token` |
-| `resource_type` | `oauth2_app_secret` |
-| `resource_type` | `organization` |
-| `resource_type` | `organization_member` |
-| `resource_type` | `provisioner_daemon` |
-| `resource_type` | `provisioner_keys` |
-| `resource_type` | `replicas` |
-| `resource_type` | `system` |
-| `resource_type` | `tailnet_coordinator` |
-| `resource_type` | `template` |
-| `resource_type` | `user` |
-| `resource_type` | `workspace` |
-| `resource_type` | `workspace_dormant` |
-| `resource_type` | `workspace_proxy` |
+| Property | Value |
+| --------------- | ------------------------- |
+| `action` | `application_connect` |
+| `action` | `assign` |
+| `action` | `create` |
+| `action` | `delete` |
+| `action` | `read` |
+| `action` | `read_personal` |
+| `action` | `ssh` |
+| `action` | `update` |
+| `action` | `update_personal` |
+| `action` | `use` |
+| `action` | `view_insights` |
+| `action` | `start` |
+| `action` | `stop` |
+| `resource_type` | `*` |
+| `resource_type` | `api_key` |
+| `resource_type` | `assign_org_role` |
+| `resource_type` | `assign_role` |
+| `resource_type` | `audit_log` |
+| `resource_type` | `debug_info` |
+| `resource_type` | `deployment_config` |
+| `resource_type` | `deployment_stats` |
+| `resource_type` | `file` |
+| `resource_type` | `group` |
+| `resource_type` | `license` |
+| `resource_type` | `notification_preference` |
+| `resource_type` | `notification_template` |
+| `resource_type` | `oauth2_app` |
+| `resource_type` | `oauth2_app_code_token` |
+| `resource_type` | `oauth2_app_secret` |
+| `resource_type` | `organization` |
+| `resource_type` | `organization_member` |
+| `resource_type` | `provisioner_daemon` |
+| `resource_type` | `provisioner_keys` |
+| `resource_type` | `replicas` |
+| `resource_type` | `system` |
+| `resource_type` | `tailnet_coordinator` |
+| `resource_type` | `template` |
+| `resource_type` | `user` |
+| `resource_type` | `workspace` |
+| `resource_type` | `workspace_dormant` |
+| `resource_type` | `workspace_proxy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
diff --git a/docs/api/notifications.md b/docs/api/notifications.md
new file mode 100644
index 0000000000000..528153ebd103b
--- /dev/null
+++ b/docs/api/notifications.md
@@ -0,0 +1,296 @@
+# Notifications
+
+## Get notification dispatch methods
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X GET http://coder-server:8080/api/v2/notifications/dispatch-methods \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`GET /notifications/dispatch-methods`
+
+### Example responses
+
+> 200 Response
+
+```json
+[
+ {
+ "available": ["string"],
+ "default": "string"
+ }
+]
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationMethodsResponse](schemas.md#codersdknotificationmethodsresponse) |
+
+Response Schema
+
+Status Code **200**
+
+| Name | Type | Required | Restrictions | Description |
+| -------------- | ------ | -------- | ------------ | ----------- |
+| `[array item]` | array | false | | |
+| `» available` | array | false | | |
+| `» default` | string | false | | |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Get notifications settings
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X GET http://coder-server:8080/api/v2/notifications/settings \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`GET /notifications/settings`
+
+### Example responses
+
+> 200 Response
+
+```json
+{
+ "notifier_paused": true
+}
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Update notifications settings
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X PUT http://coder-server:8080/api/v2/notifications/settings \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`PUT /notifications/settings`
+
+> Body parameter
+
+```json
+{
+ "notifier_paused": true
+}
+```
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+| ------ | ---- | -------------------------------------------------------------------------- | -------- | ------------------------------ |
+| `body` | body | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | true | Notifications settings request |
+
+### Example responses
+
+> 200 Response
+
+```json
+{
+ "notifier_paused": true
+}
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | --------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) |
+| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not Modified | |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Get system notification templates
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`GET /notifications/templates/system`
+
+### Example responses
+
+> 200 Response
+
+```json
+[
+ {
+ "actions": "string",
+ "body_template": "string",
+ "group": "string",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "kind": "string",
+ "method": "string",
+ "name": "string",
+ "title_template": "string"
+ }
+]
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------- |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationTemplate](schemas.md#codersdknotificationtemplate) |
+
+Response Schema
+
+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 | | |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Get user notification preferences
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X GET http://coder-server:8080/api/v2/users/{user}/notifications/preferences \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`GET /users/{user}/notifications/preferences`
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+| ------ | ---- | ------ | -------- | -------------------- |
+| `user` | path | string | true | User ID, name, or me |
+
+### Example responses
+
+> 200 Response
+
+```json
+[
+ {
+ "disabled": true,
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "updated_at": "2019-08-24T14:15:22Z"
+ }
+]
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------- |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationPreference](schemas.md#codersdknotificationpreference) |
+
+Response Schema
+
+Status Code **200**
+
+| Name | Type | Required | Restrictions | Description |
+| -------------- | ----------------- | -------- | ------------ | ----------- |
+| `[array item]` | array | false | | |
+| `» disabled` | boolean | false | | |
+| `» id` | string(uuid) | false | | |
+| `» updated_at` | string(date-time) | false | | |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Update user notification preferences
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X PUT http://coder-server:8080/api/v2/users/{user}/notifications/preferences \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`PUT /users/{user}/notifications/preferences`
+
+> Body parameter
+
+```json
+{
+ "template_disabled_map": {
+ "property1": true,
+ "property2": true
+ }
+}
+```
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+| ------ | ---- | -------------------------------------------------------------------------------------------------- | -------- | -------------------- |
+| `user` | path | string | true | User ID, name, or me |
+| `body` | body | [codersdk.UpdateUserNotificationPreferences](schemas.md#codersdkupdateusernotificationpreferences) | true | Preferences |
+
+### Example responses
+
+> 200 Response
+
+```json
+[
+ {
+ "disabled": true,
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "updated_at": "2019-08-24T14:15:22Z"
+ }
+]
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------- |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationPreference](schemas.md#codersdknotificationpreference) |
+
+Response Schema
+
+Status Code **200**
+
+| Name | Type | Required | Restrictions | Description |
+| -------------- | ----------------- | -------- | ------------ | ----------- |
+| `[array item]` | array | false | | |
+| `» disabled` | boolean | false | | |
+| `» id` | string(uuid) | false | | |
+| `» updated_at` | string(date-time) | false | | |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index 53ad820daf60c..7406d135112f1 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -3141,6 +3141,68 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `id` | string | true | | |
| `username` | string | true | | |
+## codersdk.NotificationMethodsResponse
+
+```json
+{
+ "available": ["string"],
+ "default": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+| ----------- | --------------- | -------- | ------------ | ----------- |
+| `available` | array of string | false | | |
+| `default` | string | false | | |
+
+## codersdk.NotificationPreference
+
+```json
+{
+ "disabled": true,
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "updated_at": "2019-08-24T14:15:22Z"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+| ------------ | ------- | -------- | ------------ | ----------- |
+| `disabled` | boolean | false | | |
+| `id` | string | false | | |
+| `updated_at` | string | false | | |
+
+## codersdk.NotificationTemplate
+
+```json
+{
+ "actions": "string",
+ "body_template": "string",
+ "group": "string",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "kind": "string",
+ "method": "string",
+ "name": "string",
+ "title_template": "string"
+}
+```
+
+### 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 | | |
+
## codersdk.NotificationsConfig
```json
@@ -4153,34 +4215,36 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
#### Enumerated Values
-| Value |
-| ----------------------- |
-| `*` |
-| `api_key` |
-| `assign_org_role` |
-| `assign_role` |
-| `audit_log` |
-| `debug_info` |
-| `deployment_config` |
-| `deployment_stats` |
-| `file` |
-| `group` |
-| `license` |
-| `oauth2_app` |
-| `oauth2_app_code_token` |
-| `oauth2_app_secret` |
-| `organization` |
-| `organization_member` |
-| `provisioner_daemon` |
-| `provisioner_keys` |
-| `replicas` |
-| `system` |
-| `tailnet_coordinator` |
-| `template` |
-| `user` |
-| `workspace` |
-| `workspace_dormant` |
-| `workspace_proxy` |
+| Value |
+| ------------------------- |
+| `*` |
+| `api_key` |
+| `assign_org_role` |
+| `assign_role` |
+| `audit_log` |
+| `debug_info` |
+| `deployment_config` |
+| `deployment_stats` |
+| `file` |
+| `group` |
+| `license` |
+| `notification_preference` |
+| `notification_template` |
+| `oauth2_app` |
+| `oauth2_app_code_token` |
+| `oauth2_app_secret` |
+| `organization` |
+| `organization_member` |
+| `provisioner_daemon` |
+| `provisioner_keys` |
+| `replicas` |
+| `system` |
+| `tailnet_coordinator` |
+| `template` |
+| `user` |
+| `workspace` |
+| `workspace_dormant` |
+| `workspace_proxy` |
## codersdk.RateLimitConfig
@@ -5535,6 +5599,24 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| ------------------ | ------ | -------- | ------------ | ----------- |
| `theme_preference` | string | true | | |
+## codersdk.UpdateUserNotificationPreferences
+
+```json
+{
+ "template_disabled_map": {
+ "property1": true,
+ "property2": true
+ }
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+| ----------------------- | ------- | -------- | ------------ | ----------- |
+| `template_disabled_map` | object | false | | |
+| » `[any property]` | boolean | false | | |
+
## codersdk.UpdateUserPasswordRequest
```json
diff --git a/docs/manifest.json b/docs/manifest.json
index 82dd73ada47c8..4b686ed9598b6 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -601,6 +601,10 @@
"title": "Members",
"path": "./api/members.md"
},
+ {
+ "title": "Notifications",
+ "path": "./api/notifications.md"
+ },
{
"title": "Organizations",
"path": "./api/organizations.md"
diff --git a/enterprise/audit/diff.go b/enterprise/audit/diff.go
index 007f475f6f5eb..07cd8a5fdcb87 100644
--- a/enterprise/audit/diff.go
+++ b/enterprise/audit/diff.go
@@ -142,6 +142,13 @@ func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
}
return leftInt64Ptr, rightInt64Ptr, true
+ case database.NullNotificationMethod:
+ vl, vr := string(typedLeft.NotificationMethod), ""
+ if val, ok := right.(database.NullNotificationMethod); ok {
+ vr = string(val.NotificationMethod)
+ }
+
+ return vl, vr, true
case database.TemplateACL:
return fmt.Sprintf("%+v", left), fmt.Sprintf("%+v", right), true
case database.CustomRolePermissions:
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index dcecd88971af8..7310848f4f959 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -272,6 +272,16 @@ var auditableResourcesTypes = map[any]map[string]Action{
"display_name": ActionTrack,
"icon": ActionTrack,
},
+ &database.NotificationTemplate{}: {
+ "id": ActionIgnore,
+ "name": ActionTrack,
+ "title_template": ActionTrack,
+ "body_template": ActionTrack,
+ "actions": ActionTrack,
+ "group": ActionTrack,
+ "method": ActionTrack,
+ "kind": ActionTrack,
+ },
}
// auditMap converts a map of struct pointers to a map of struct names as
diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go
index e9e8d7d196af0..5fbd1569d0207 100644
--- a/enterprise/coderd/coderd.go
+++ b/enterprise/coderd/coderd.go
@@ -368,7 +368,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Put("/", api.putAppearance)
})
})
-
r.Route("/users/{user}/quiet-hours", func(r chi.Router) {
r.Use(
api.autostopRequirementEnabledMW,
@@ -388,6 +387,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Post("/jfrog/xray-scan", api.postJFrogXrayScan)
r.Get("/jfrog/xray-scan", api.jFrogXrayScan)
})
+
+ // The /notifications base route is mounted by the AGPL router, so we can't group it here.
+ // Additionally, because we have a static route for /notifications/templates/system which conflicts
+ // with the below route, we need to register this route without any mounts or groups to make both work.
+ r.With(
+ apiKeyMiddleware,
+ httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentNotifications),
+ httpmw.ExtractNotificationTemplateParam(options.Database),
+ ).Put("/notifications/templates/{notification_template}/method", api.updateNotificationTemplateMethod)
})
if len(options.SCIMAPIKey) != 0 {
diff --git a/enterprise/coderd/notifications.go b/enterprise/coderd/notifications.go
new file mode 100644
index 0000000000000..3f3ea2b911026
--- /dev/null
+++ b/enterprise/coderd/notifications.go
@@ -0,0 +1,98 @@
+package coderd
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/coderd/audit"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/httpapi"
+ "github.com/coder/coder/v2/coderd/httpmw"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+// @Summary Update notification template dispatch method
+// @ID update-notification-template-dispatch-method
+// @Security CoderSessionToken
+// @Produce json
+// @Param notification_template path string true "Notification template UUID"
+// @Tags Enterprise
+// @Success 200 "Success"
+// @Success 304 "Not modified"
+// @Router /notifications/templates/{notification_template}/method [put]
+func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http.Request) {
+ var (
+ ctx = r.Context()
+ template = httpmw.NotificationTemplateParam(r)
+ auditor = api.AGPL.Auditor.Load()
+ aReq, commitAudit = audit.InitRequest[database.NotificationTemplate](rw, &audit.RequestParams{
+ Audit: *auditor,
+ Log: api.Logger,
+ Request: r,
+ Action: database.AuditActionWrite,
+ })
+ )
+
+ var req codersdk.UpdateNotificationTemplateMethod
+ if !httpapi.Read(ctx, rw, r, &req) {
+ return
+ }
+
+ var nm database.NullNotificationMethod
+ if err := nm.Scan(req.Method); err != nil || !nm.Valid || !nm.NotificationMethod.Valid() {
+ vals := database.AllNotificationMethodValues()
+ acceptable := make([]string, len(vals))
+ for i, v := range vals {
+ acceptable[i] = string(v)
+ }
+
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid request to update notification template method",
+ Validations: []codersdk.ValidationError{
+ {
+ Field: "method",
+ Detail: fmt.Sprintf("%q is not a valid method; %s are the available options",
+ req.Method, strings.Join(acceptable, ", "),
+ ),
+ },
+ },
+ })
+ return
+ }
+
+ if template.Method == nm {
+ httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{
+ Message: "Notification template method unchanged.",
+ })
+ return
+ }
+
+ defer commitAudit()
+ aReq.Old = template
+
+ err := api.Database.InTx(func(tx database.Store) error {
+ var err error
+ template, err = api.Database.UpdateNotificationTemplateMethodByID(r.Context(), database.UpdateNotificationTemplateMethodByIDParams{
+ ID: template.ID,
+ Method: nm,
+ })
+ if err != nil {
+ return xerrors.Errorf("failed to update notification template ID: %w", err)
+ }
+
+ return err
+ }, nil)
+ if err != nil {
+ httpapi.InternalServerError(rw, err)
+ return
+ }
+
+ aReq.New = template
+
+ httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
+ Message: "Successfully updated notification template method.",
+ })
+}
diff --git a/enterprise/coderd/notifications_test.go b/enterprise/coderd/notifications_test.go
new file mode 100644
index 0000000000000..5546bec1dcb79
--- /dev/null
+++ b/enterprise/coderd/notifications_test.go
@@ -0,0 +1,180 @@
+package coderd_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/coderd/coderdtest"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbtestutil"
+ "github.com/coder/coder/v2/coderd/notifications"
+ "github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
+ "github.com/coder/coder/v2/testutil"
+)
+
+func createOpts(t *testing.T) *coderdenttest.Options {
+ t.Helper()
+
+ dt := coderdtest.DeploymentValues(t)
+ dt.Experiments = []string{string(codersdk.ExperimentNotifications)}
+ return &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ DeploymentValues: dt,
+ },
+ }
+}
+
+func TestUpdateNotificationTemplateMethod(t *testing.T) {
+ t.Parallel()
+
+ t.Run("Happy path", func(t *testing.T) {
+ t.Parallel()
+
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table")
+ }
+
+ ctx := testutil.Context(t, testutil.WaitSuperLong)
+ api, _ := coderdenttest.New(t, createOpts(t))
+
+ var (
+ method = string(database.NotificationMethodSmtp)
+ templateID = notifications.TemplateWorkspaceDeleted
+ )
+
+ // Given: a template whose method is initially empty (i.e. deferring to the global method value).
+ template, err := getTemplateByID(t, ctx, api, templateID)
+ require.NoError(t, err)
+ require.NotNil(t, template)
+ require.Empty(t, template.Method)
+
+ // When: calling the API to update the method.
+ require.NoError(t, api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method), "initial request to set the method failed")
+
+ // Then: the method should be set.
+ template, err = getTemplateByID(t, ctx, api, templateID)
+ require.NoError(t, err)
+ require.NotNil(t, template)
+ require.Equal(t, method, template.Method)
+ })
+
+ t.Run("Insufficient permissions", func(t *testing.T) {
+ t.Parallel()
+
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table")
+ }
+
+ ctx := testutil.Context(t, testutil.WaitSuperLong)
+
+ // Given: the first user which has an "owner" role, and another user which does not.
+ api, firstUser := coderdenttest.New(t, createOpts(t))
+ anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
+
+ // When: calling the API as an unprivileged user.
+ err := anotherClient.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, string(database.NotificationMethodWebhook))
+
+ // Then: the request is denied because of insufficient permissions.
+ var sdkError *codersdk.Error
+ require.Error(t, err)
+ require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
+ require.Equal(t, http.StatusNotFound, sdkError.StatusCode())
+ require.Equal(t, "Resource not found or you do not have access to this resource", sdkError.Response.Message)
+ })
+
+ t.Run("Invalid notification method", func(t *testing.T) {
+ t.Parallel()
+
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table")
+ }
+
+ ctx := testutil.Context(t, testutil.WaitSuperLong)
+
+ // Given: the first user which has an "owner" role
+ api, _ := coderdenttest.New(t, createOpts(t))
+
+ // When: calling the API with an invalid method.
+ const method = "nope"
+
+ // nolint:gocritic // Using an owner-scope user is kinda the point.
+ err := api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method)
+
+ // Then: the request is invalid because of the unacceptable method.
+ var sdkError *codersdk.Error
+ require.Error(t, err)
+ require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
+ require.Equal(t, http.StatusBadRequest, sdkError.StatusCode())
+ require.Equal(t, "Invalid request to update notification template method", sdkError.Response.Message)
+ require.Len(t, sdkError.Response.Validations, 1)
+ require.Equal(t, "method", sdkError.Response.Validations[0].Field)
+ require.Equal(t, fmt.Sprintf("%q is not a valid method; smtp, webhook are the available options", method), sdkError.Response.Validations[0].Detail)
+ })
+
+ t.Run("Not modified", func(t *testing.T) {
+ t.Parallel()
+
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table")
+ }
+
+ ctx := testutil.Context(t, testutil.WaitSuperLong)
+ api, _ := coderdenttest.New(t, createOpts(t))
+
+ var (
+ method = string(database.NotificationMethodSmtp)
+ templateID = notifications.TemplateWorkspaceDeleted
+ )
+
+ template, err := getTemplateByID(t, ctx, api, templateID)
+ require.NoError(t, err)
+ require.NotNil(t, template)
+
+ // Given: a template whose method is initially empty (i.e. deferring to the global method value).
+ require.Empty(t, template.Method)
+
+ // When: calling the API to update the method, it should set it.
+ require.NoError(t, api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method), "initial request to set the method failed")
+ template, err = getTemplateByID(t, ctx, api, templateID)
+ require.NoError(t, err)
+ require.NotNil(t, template)
+ require.Equal(t, method, template.Method)
+
+ // Then: when calling the API again with the same method, the method will remain unchanged.
+ require.NoError(t, api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method), "second request to set the method failed")
+ template, err = getTemplateByID(t, ctx, api, templateID)
+ require.NoError(t, err)
+ require.NotNil(t, template)
+ require.Equal(t, method, template.Method)
+ })
+}
+
+// nolint:revive // t takes precedence.
+func getTemplateByID(t *testing.T, ctx context.Context, api *codersdk.Client, id uuid.UUID) (*codersdk.NotificationTemplate, error) {
+ t.Helper()
+
+ var template codersdk.NotificationTemplate
+ templates, err := api.GetSystemNotificationTemplates(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, tmpl := range templates {
+ if tmpl.ID == id {
+ template = tmpl
+ }
+ }
+
+ if template.ID == uuid.Nil {
+ return nil, xerrors.Errorf("template not found: %q", id.String())
+ }
+
+ return &template, nil
+}
diff --git a/site/src/api/rbacresources_gen.ts b/site/src/api/rbacresources_gen.ts
index 37fe508fde89c..6cee389dfbc7a 100644
--- a/site/src/api/rbacresources_gen.ts
+++ b/site/src/api/rbacresources_gen.ts
@@ -55,6 +55,14 @@ export const RBACResourceActions: Partial<
delete: "delete license",
read: "read licenses",
},
+ notification_preference: {
+ read: "read notification preferences",
+ update: "update notification preferences",
+ },
+ notification_template: {
+ read: "read notification templates",
+ update: "update notification templates",
+ },
oauth2_app: {
create: "make an OAuth2 app.",
delete: "delete an OAuth2 app",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 6aa052ad94c2c..5c2dc816fea1e 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -709,6 +709,31 @@ export interface MinimalUser {
readonly avatar_url: string;
}
+// From codersdk/notifications.go
+export interface NotificationMethodsResponse {
+ readonly available: readonly string[];
+ readonly default: string;
+}
+
+// From codersdk/notifications.go
+export interface NotificationPreference {
+ readonly id: string;
+ readonly disabled: boolean;
+ readonly updated_at: string;
+}
+
+// From codersdk/notifications.go
+export interface NotificationTemplate {
+ readonly id: string;
+ readonly name: string;
+ readonly title_template: string;
+ readonly body_template: string;
+ readonly actions: string;
+ readonly group: string;
+ readonly method: string;
+ readonly kind: string;
+}
+
// From codersdk/deployment.go
export interface NotificationsConfig {
readonly max_send_attempts: number;
@@ -1447,6 +1472,11 @@ export interface UpdateCheckResponse {
readonly url: string;
}
+// From codersdk/notifications.go
+export interface UpdateNotificationTemplateMethod {
+ readonly method?: string;
+}
+
// From codersdk/organizations.go
export interface UpdateOrganizationRequest {
readonly name?: string;
@@ -1495,6 +1525,11 @@ export interface UpdateUserAppearanceSettingsRequest {
readonly theme_preference: string;
}
+// From codersdk/notifications.go
+export interface UpdateUserNotificationPreferences {
+ readonly template_disabled_map: Record;
+}
+
// From codersdk/users.go
export interface UpdateUserPasswordRequest {
readonly old_password: string;
@@ -2269,6 +2304,8 @@ export type RBACResource =
| "file"
| "group"
| "license"
+ | "notification_preference"
+ | "notification_template"
| "oauth2_app"
| "oauth2_app_code_token"
| "oauth2_app_secret"
@@ -2296,6 +2333,8 @@ export const RBACResources: RBACResource[] = [
"file",
"group",
"license",
+ "notification_preference",
+ "notification_template",
"oauth2_app",
"oauth2_app_code_token",
"oauth2_app_secret",