diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml
index b132f220889aa..5f04ae95d1598 100644
--- a/.github/workflows/dogfood.yaml
+++ b/.github/workflows/dogfood.yaml
@@ -10,8 +10,6 @@ on:
- "flake.lock"
- "flake.nix"
pull_request:
- branches-ignore:
- - "dependabot/**"
paths:
- "dogfood/**"
- ".github/workflows/dogfood.yaml"
@@ -21,6 +19,7 @@ on:
jobs:
build_image:
+ if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs
runs-on: ubuntu-latest
steps:
- name: Checkout
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index b72db00912ff7..8fdcd232c0958 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -1558,7 +1558,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "General"
+ "Notifications"
],
"summary": "Get notifications settings",
"operationId": "get-notifications-settings",
@@ -1584,7 +1584,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "General"
+ "Notifications"
],
"summary": "Update notifications settings",
"operationId": "update-notifications-settings",
@@ -1612,6 +1612,56 @@ 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": "post-notification-template-method",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ },
"/oauth2-provider/apps": {
"get": {
"security": [
@@ -10199,6 +10249,36 @@ const docTemplate = `{
}
}
},
+ "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": {
@@ -11214,6 +11294,8 @@ const docTemplate = `{
"file",
"group",
"license",
+ "notification_preference",
+ "notification_template",
"oauth2_app",
"oauth2_app_code_token",
"oauth2_app_secret",
@@ -11242,6 +11324,8 @@ const docTemplate = `{
"ResourceFile",
"ResourceGroup",
"ResourceLicense",
+ "ResourceNotificationPreference",
+ "ResourceNotificationTemplate",
"ResourceOauth2App",
"ResourceOauth2AppCodeToken",
"ResourceOauth2AppSecret",
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index bf7216f1f313b..2abb9f262ddb1 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -1352,7 +1352,7 @@
}
],
"produces": ["application/json"],
- "tags": ["General"],
+ "tags": ["Notifications"],
"summary": "Get notifications settings",
"operationId": "get-notifications-settings",
"responses": {
@@ -1372,7 +1372,7 @@
],
"consumes": ["application/json"],
"produces": ["application/json"],
- "tags": ["General"],
+ "tags": ["Notifications"],
"summary": "Update notifications settings",
"operationId": "update-notifications-settings",
"parameters": [
@@ -1399,6 +1399,48 @@
}
}
},
+ "/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": "post-notification-template-method",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ },
"/oauth2-provider/apps": {
"get": {
"security": [
@@ -9140,6 +9182,36 @@
}
}
},
+ "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": {
@@ -10116,6 +10188,8 @@
"file",
"group",
"license",
+ "notification_preference",
+ "notification_template",
"oauth2_app",
"oauth2_app_code_token",
"oauth2_app_secret",
@@ -10144,6 +10218,8 @@
"ResourceFile",
"ResourceGroup",
"ResourceLicense",
+ "ResourceNotificationPreference",
+ "ResourceNotificationTemplate",
"ResourceOauth2App",
"ResourceOauth2AppCodeToken",
"ResourceOauth2AppSecret",
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..b45e5219a74d0 100644
--- a/coderd/coderd.go
+++ b/coderd/coderd.go
@@ -1243,9 +1243,15 @@ 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)
+ })
})
})
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index b7cff64e2a57b..3e90598c61365 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -1474,6 +1474,21 @@ 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?
+ 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 +2100,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 +3033,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
@@ -3309,6 +3338,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 876c0d797f64a..8b579c4482cf7 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"
@@ -2555,6 +2556,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)
@@ -2590,6 +2595,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 f32de78b72714..d8782fc5811cc 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,14 @@ func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg dat
return out, nil
}
+func (*FakeQuerier) GetNotificationTemplateByID(_ context.Context, _ uuid.UUID) (database.NotificationTemplate, error) {
+ return database.NotificationTemplate{}, ErrUnimplemented
+}
+
+func (*FakeQuerier) GetNotificationTemplatesByKind(_ context.Context, _ database.NotificationTemplateKind) ([]database.NotificationTemplate, error) {
+ return nil, ErrUnimplemented
+}
+
func (q *FakeQuerier) GetNotificationsSettings(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -4853,6 +4863,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 +7546,10 @@ func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMe
return database.OrganizationMember{}, sql.ErrNoRows
}
+func (*FakeQuerier) UpdateNotificationTemplateMethodByID(_ context.Context, _ database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) {
+ return database.NotificationTemplate{}, ErrUnimplemented
+}
+
func (q *FakeQuerier) UpdateOAuth2ProviderAppByID(_ context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
err := validateDatabaseType(arg)
if err != nil {
@@ -8094,6 +8124,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 -1, 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 = time.Now()
+ q.notificationPreferences[j] = np
+
+ upserted++
+ found = true
+ break
+ }
+
+ if !found {
+ np := database.NotificationPreference{
+ Disabled: disabled,
+ UserID: arg.UserID,
+ NotificationTemplateID: templateID,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.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 2b25591568f8c..b3e5ee86528bd 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)
@@ -2132,6 +2160,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 b91ba6c8bd5d8..372dc0a0d631a 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()
@@ -4490,6 +4550,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 dc15cf9bd4af8..a7445b6b0401b 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,
@@ -1533,6 +1568,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);
@@ -1638,6 +1676,9 @@ ALTER TABLE ONLY template_versions
ALTER TABLE ONLY templates
ADD CONSTRAINT templates_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY notification_preferences
+ ADD CONSTRAINT unique_user_notification_template UNIQUE (user_id, notification_template_id);
+
ALTER TABLE ONLY user_links
ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type);
@@ -1795,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();
@@ -1848,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/000234_fix_notifications_user_created.down.sql b/coderd/database/migrations/000234_fix_notifications_user_created.down.sql
new file mode 100644
index 0000000000000..526b9aef53e5a
--- /dev/null
+++ b/coderd/database/migrations/000234_fix_notifications_user_created.down.sql
@@ -0,0 +1,5 @@
+UPDATE notification_templates
+SET
+ body_template = E'Hi {{.UserName}},\n\New user account **{{.Labels.created_account_name}}** has been created.'
+WHERE
+ id = '4e19c0ac-94e1-4532-9515-d1801aa283b2';
diff --git a/coderd/database/migrations/000234_fix_notifications_user_created.up.sql b/coderd/database/migrations/000234_fix_notifications_user_created.up.sql
new file mode 100644
index 0000000000000..5fb59dbd2ecdf
--- /dev/null
+++ b/coderd/database/migrations/000234_fix_notifications_user_created.up.sql
@@ -0,0 +1,5 @@
+UPDATE notification_templates
+SET
+ body_template = E'Hi {{.UserName}},\n\nNew user account **{{.Labels.created_account_name}}** has been created.'
+WHERE
+ id = '4e19c0ac-94e1-4532-9515-d1801aa283b2';
diff --git a/coderd/database/migrations/000234_notification_preferences.down.sql b/coderd/database/migrations/000234_notification_preferences.down.sql
new file mode 100644
index 0000000000000..5e894d93e5289
--- /dev/null
+++ b/coderd/database/migrations/000234_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/000234_notification_preferences.up.sql b/coderd/database/migrations/000234_notification_preferences.up.sql
new file mode 100644
index 0000000000000..417786d2fe6ff
--- /dev/null
+++ b/coderd/database/migrations/000234_notification_preferences.up.sql
@@ -0,0 +1,56 @@
+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)
+);
+
+-- Ensure we cannot insert multiple entries for the same user/template combination.
+ALTER TABLE notification_preferences
+ ADD CONSTRAINT unique_user_notification_template UNIQUE (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/000234_notifications_preferences.up.sql b/coderd/database/migrations/testdata/fixtures/000234_notifications_preferences.up.sql
new file mode 100644
index 0000000000000..5795ca15dc5f8
--- /dev/null
+++ b/coderd/database/migrations/testdata/fixtures/000234_notifications_preferences.up.sql
@@ -0,0 +1,9 @@
+INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
+VALUES ('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'githubuser@coder.com', 'githubuser', '\x',
+ '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', false) ON CONFLICT DO NOTHING;
+
+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 0ee78e286516e..ab12f5284b0cd 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 9d0494813e306..bbc2983fe62fb 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)
@@ -426,6 +430,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 904f304bd25a9..3813200b4627b 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -3334,14 +3334,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 {
@@ -3360,6 +3364,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.
@@ -3395,6 +3400,7 @@ func (q *sqlQuerier) AcquireNotificationMessages(ctx context.Context, arg Acquir
&i.TemplateID,
&i.TitleTemplate,
&i.BodyTemplate,
+ &i.Disabled,
); err != nil {
return nil, err
}
@@ -3573,7 +3579,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 {
@@ -3620,6 +3629,153 @@ 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..d62564ea6edbf 100644
--- a/coderd/database/queries/notifications.sql
+++ b/coderd/database/queries/notifications.sql
@@ -79,14 +79,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 +135,37 @@ 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..f6d37d9bf67a3 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);
@@ -59,6 +60,7 @@ const (
UniqueTemplateVersionsPkey UniqueConstraint = "template_versions_pkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id);
UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name);
UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id);
+ UniqueUniqueUserNotificationTemplate UniqueConstraint = "unique_user_notification_template" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT unique_user_notification_template UNIQUE (user_id, notification_template_id);
UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type);
UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id);
UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id);
diff --git a/coderd/httpmw/notificationtemplateparam.go b/coderd/httpmw/notificationtemplateparam.go
new file mode 100644
index 0000000000000..ae86d48d033e8
--- /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 param middleware not provided")
+ }
+ 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..0545f19ec2026 100644
--- a/coderd/notifications.go
+++ b/coderd/notifications.go
@@ -11,7 +11,6 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/rbac"
- "github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)
@@ -19,7 +18,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 +50,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 +58,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 +72,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 +83,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
}
@@ -110,13 +102,58 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request
}
err = api.Database.UpsertNotificationsSettings(ctx, string(settingsJSON))
+ if err != nil {
+ if rbac.IsUnauthorizedError(err) {
+ httpapi.Forbidden(rw)
+ } else {
+ 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 update notifications settings.",
+ Message: "Failed to retrieve system notifications templates.",
Detail: err.Error(),
})
return
}
- httpapi.Write(r.Context(), rw, http.StatusOK, settings)
+ out := convertNotificationTemplates(templates)
+ httpapi.Write(r.Context(), 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
}
diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go
index 7690154a0db80..19bc3044f8d68 100644
--- a/coderd/notifications_test.go
+++ b/coderd/notifications_test.go
@@ -11,13 +11,23 @@ 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 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 +51,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 +75,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)
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..b52fc0beb9c6b 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.InOrg(orgID).WithOwner(uuid.NewString()), // some other user
+ AuthorizeMap: map[bool][]hasAuthSubjects{
+ true: {orgAdmin, owner},
+ false: {
+ memberMe, templateAdmin, orgUserAdmin, userAdmin,
+ orgAuditor, orgTemplateAdmin,
+ otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
+ otherOrgAdmin, orgMemberMe,
+ },
+ },
+ },
// AnyOrganization tests
{
Name: "CreateOrgMember",
@@ -630,6 +678,37 @@ func TestRolePermissions(t *testing.T) {
},
},
},
+ {
+ // Notification preferences are currently not organization-scoped
+ // Any owner/admin across any organization may access any users' preferences
+ // Members may access their own preferences
+ Name: "NotificationPreferencesAnyOrg",
+ Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
+ Resource: rbac.ResourceNotificationPreference.AnyOrganization().WithOwner(currentUser.String()),
+ AuthorizeMap: map[bool][]hasAuthSubjects{
+ true: {orgMemberMe, orgAdmin, otherOrgAdmin, owner},
+ false: {
+ memberMe, templateAdmin, otherOrgUserAdmin, userAdmin, orgUserAdmin,
+ orgAuditor, orgTemplateAdmin,
+ otherOrgMember, otherOrgAuditor, otherOrgTemplateAdmin,
+ },
+ },
+ },
+ {
+ // Notification templates are currently not organization-scoped
+ // Any owner/admin across any organization may access notification templates
+ Name: "NotificationTemplateAnyOrg",
+ Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
+ Resource: rbac.ResourceNotificationPreference.AnyOrganization(),
+ AuthorizeMap: map[bool][]hasAuthSubjects{
+ true: {orgAdmin, otherOrgAdmin, owner},
+ false: {
+ orgMemberMe, memberMe, templateAdmin, orgUserAdmin, userAdmin,
+ orgAuditor, orgTemplateAdmin,
+ otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
+ },
+ },
+ },
}
// We expect every permission to be tested above.
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..0e690615497d0 100644
--- a/codersdk/notifications.go
+++ b/codersdk/notifications.go
@@ -3,13 +3,31 @@ package codersdk
import (
"context"
"encoding/json"
+ "fmt"
+ "io"
"net/http"
+
+ "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"`
+}
+
+// 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 +41,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 +58,53 @@ 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
+}
+
+type UpdateNotificationTemplateMethod struct {
+ Method string `json:"method,omitempty" example:"webhook"`
+}
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 87f07cf243125..7b5375fff94f3 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..a4de96701c817 100644
--- a/docs/api/enterprise.md
+++ b/docs/api/enterprise.md
@@ -537,6 +537,26 @@ 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`
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | ------------------------------------------------------- | ----------- | ------ |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | |
+
+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..ca43565a982a9
--- /dev/null
+++ b/docs/api/notifications.md
@@ -0,0 +1,135 @@
+# Notifications
+
+## 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).
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index c1ec9979a0a13..b50ce7b6b230d 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -3136,6 +3136,34 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `id` | string | true | | |
| `username` | string | true | | |
+## 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
@@ -4148,34 +4176,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
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 0a3608dae7169..8474bc8a6cd16 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -271,6 +271,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..bd51ac3d803c1
--- /dev/null
+++ b/enterprise/coderd/notifications.go
@@ -0,0 +1,97 @@
+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 post-notification-template-method
+// @Security CoderSessionToken
+// @Produce json
+// @Tags Enterprise
+// @Success 200
+// @Router /notifications/templates/{notification_template}/method [put]
+func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http.Request) {
+ // TODO: authorization (restrict to admin/template admin?)
+ 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..7ada95292b8f1
--- /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/dbmem"
+ "github.com/coder/coder/v2/coderd/database/dbtestutil"
+ "github.com/coder/coder/v2/coderd/database/pubsub"
+ "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, usePostgres bool) *coderdenttest.Options {
+ t.Helper()
+
+ var (
+ db database.Store
+ ps pubsub.Pubsub
+ )
+
+ if usePostgres {
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table")
+ }
+
+ db, ps = dbtestutil.NewDB(t)
+ } else {
+ db, ps = dbmem.New(), pubsub.NewInMemory()
+ }
+
+ dt := coderdtest.DeploymentValues(t)
+ dt.Experiments = []string{string(codersdk.ExperimentNotifications)}
+ return &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ DeploymentValues: dt,
+ Database: db,
+ Pubsub: ps,
+ },
+ }
+}
+
+func TestUpdateNotificationTemplateMethod(t *testing.T) {
+ t.Parallel()
+
+ t.Run("Happy path", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitSuperLong)
+ api, _ := coderdenttest.New(t, createOpts(t, true))
+
+ 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()
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ // Given: the first user which has an "owner" role, and another user which does not.
+ api, firstUser := coderdenttest.New(t, createOpts(t, false))
+ 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()
+
+ ctx := testutil.Context(t, testutil.WaitSuperLong)
+
+ // Given: the first user which has an "owner" role
+ api, _ := coderdenttest.New(t, createOpts(t, true))
+
+ // When: calling the API with an invalid method.
+ const method = "nope"
+ 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()
+
+ ctx := testutil.Context(t, testutil.WaitSuperLong)
+ api, _ := coderdenttest.New(t, createOpts(t, true))
+
+ 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)
+ })
+}
+
+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 == 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 0ad30e1310cff..daa16a50454d4 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -708,6 +708,18 @@ export interface MinimalUser {
readonly avatar_url: 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;
@@ -1437,6 +1449,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;
@@ -2259,6 +2276,8 @@ export type RBACResource =
| "file"
| "group"
| "license"
+ | "notification_preference"
+ | "notification_template"
| "oauth2_app"
| "oauth2_app_code_token"
| "oauth2_app_secret"
@@ -2286,6 +2305,8 @@ export const RBACResources: RBACResource[] = [
"file",
"group",
"license",
+ "notification_preference",
+ "notification_template",
"oauth2_app",
"oauth2_app_code_token",
"oauth2_app_secret",