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