feat: add notification preferences database & audit support (#14100)

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