From 2ab1c4d7151e2f0646bd67fa63f76593f14ef22c Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Tue, 9 Sep 2025 17:04:17 +0000 Subject: [PATCH 01/10] feat: support custom notifications --- cli/notifications.go | 34 +++++++++- cli/notifications_test.go | 63 +++++++++++++++++ .../coder_notifications_--help.golden | 11 ++- .../coder_notifications_custom_--help.golden | 9 +++ coderd/apidoc/docs.go | 59 ++++++++++++++++ coderd/apidoc/swagger.json | 53 +++++++++++++++ coderd/coderd.go | 1 + .../000367_add_custom_notifications.down.sql | 2 + .../000367_add_custom_notifications.up.sql | 21 ++++++ coderd/notifications.go | 57 ++++++++++++++++ coderd/notifications/events.go | 3 +- coderd/notifications/notifications_test.go | 14 ++++ .../TemplateCustomNotification.html.golden | 68 +++++++++++++++++++ .../TemplateCustomNotification.json.golden | 24 +++++++ coderd/notifications_test.go | 65 ++++++++++++++++++ codersdk/notifications.go | 18 +++++ docs/admin/monitoring/notifications/index.md | 37 +++++++++- docs/manifest.json | 5 ++ docs/reference/api/notifications.md | 56 +++++++++++++++ docs/reference/api/schemas.md | 16 +++++ docs/reference/cli/notifications.md | 20 ++++-- docs/reference/cli/notifications_custom.md | 10 +++ site/src/api/typesGenerated.ts | 6 ++ 23 files changed, 639 insertions(+), 13 deletions(-) create mode 100644 cli/testdata/coder_notifications_custom_--help.golden create mode 100644 coderd/database/migrations/000367_add_custom_notifications.down.sql create mode 100644 coderd/database/migrations/000367_add_custom_notifications.up.sql create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateCustomNotification.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateCustomNotification.json.golden create mode 100644 docs/reference/cli/notifications_custom.md diff --git a/cli/notifications.go b/cli/notifications.go index 1769ef3aa154a..3f146a37148b3 100644 --- a/cli/notifications.go +++ b/cli/notifications.go @@ -16,7 +16,7 @@ func (r *RootCmd) notifications() *serpent.Command { Short: "Manage Coder notifications", Long: "Administrators can use these commands to change notification settings.\n" + FormatExamples( Example{ - Description: "Pause Coder notifications. Administrators can temporarily stop notifiers from dispatching messages in case of the target outage (for example: unavailable SMTP server or Webhook not responding).", + Description: "Pause Coder notifications. Administrators can temporarily stop notifiers from dispatching messages in case of the target outage (for example: unavailable SMTP server or Webhook not responding)", Command: "coder notifications pause", }, Example{ @@ -24,9 +24,13 @@ func (r *RootCmd) notifications() *serpent.Command { Command: "coder notifications resume", }, Example{ - Description: "Send a test notification. Administrators can use this to verify the notification target settings.", + Description: "Send a test notification. Administrators can use this to verify the notification target settings", Command: "coder notifications test", }, + Example{ + Description: "Send a custom notification to the requesting user. The recipient is always the requesting user as targeting other users or groups isn’t supported yet", + Command: "coder notifications custom \"Custom Title\" \"Custom Message\"", + }, ), Aliases: []string{"notification"}, Handler: func(inv *serpent.Invocation) error { @@ -36,6 +40,7 @@ func (r *RootCmd) notifications() *serpent.Command { r.pauseNotifications(), r.resumeNotifications(), r.testNotifications(), + r.customNotifications(), }, } return cmd @@ -109,3 +114,28 @@ func (r *RootCmd) testNotifications() *serpent.Command { } return cmd } + +func (r *RootCmd) customNotifications() *serpent.Command { + client := new(codersdk.Client) + cmd := &serpent.Command{ + Use: "custom <message>", + Short: "Send a custom notification", + Middleware: serpent.Chain( + serpent.RequireNArgs(2), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + err := client.PostCustomNotification(inv.Context(), codersdk.CustomNotification{ + Title: inv.Args[0], + Message: inv.Args[1], + }) + if err != nil { + return xerrors.Errorf("unable to post custom notification: %w", err) + } + + _, _ = fmt.Fprintln(inv.Stderr, "A custom notification has been sent.") + return nil + }, + } + return cmd +} diff --git a/cli/notifications_test.go b/cli/notifications_test.go index 0e8ece285b450..5b3dba74bf038 100644 --- a/cli/notifications_test.go +++ b/cli/notifications_test.go @@ -166,3 +166,66 @@ func TestNotificationsTest(t *testing.T) { require.Len(t, sent, 0) }) } + +func TestCustomNotifications(t *testing.T) { + t.Parallel() + + t.Run("BadRequest", func(t *testing.T) { + t.Parallel() + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + + // Given: A member user + ownerClient := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + ownerUser := coderdtest.CreateFirstUser(t, ownerClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) + + // When: The member user attempts to send a custom notification with empty title and message + inv, root := clitest.New(t, "notifications", "custom", "", "") + clitest.SetupConfig(t, memberClient, root) + + // Then: an error is expected with no notifications sent + err := inv.Run() + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + assert.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + require.Equal(t, "Invalid request body", sdkError.Message) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification)) + require.Len(t, sent, 0) + }) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + + // Given: A member user + ownerClient := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + ownerUser := coderdtest.CreateFirstUser(t, ownerClient) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) + + // When: The member user attempts to send a custom notification + inv, root := clitest.New(t, "notifications", "custom", "Custom Title", "Custom Message") + clitest.SetupConfig(t, memberClient, root) + + // Then: we expect a custom notification to be sent to the member user + err := inv.Run() + require.NoError(t, err) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateCustomNotification)) + require.Len(t, sent, 1) + require.Equal(t, memberUser.ID, sent[0].UserID) + require.Len(t, sent[0].Labels, 2) + require.Equal(t, "Custom Title", sent[0].Labels["custom_title"]) + require.Equal(t, "Custom Message", sent[0].Labels["custom_message"]) + require.Equal(t, memberUser.ID.String(), sent[0].CreatedBy) + }) +} diff --git a/cli/testdata/coder_notifications_--help.golden b/cli/testdata/coder_notifications_--help.golden index ced45ca0da6e5..cfb70e2a9d5fa 100644 --- a/cli/testdata/coder_notifications_--help.golden +++ b/cli/testdata/coder_notifications_--help.golden @@ -12,7 +12,7 @@ USAGE: from dispatching messages in case of the target outage (for example: unavailable SMTP - server or Webhook not responding).: + server or Webhook not responding): $ coder notifications pause @@ -22,11 +22,18 @@ USAGE: - Send a test notification. Administrators can use this to verify the notification - target settings.: + target settings: $ coder notifications test + + - Send a custom notification to the requesting user. The recipient is always + the + requesting user as targeting other users or groups isn’t supported yet: + + $ coder notifications custom "Custom Title" "Custom Message" SUBCOMMANDS: + custom Send a custom notification pause Pause notifications resume Resume notifications test Send a test notification diff --git a/cli/testdata/coder_notifications_custom_--help.golden b/cli/testdata/coder_notifications_custom_--help.golden new file mode 100644 index 0000000000000..eeedc322715ab --- /dev/null +++ b/cli/testdata/coder_notifications_custom_--help.golden @@ -0,0 +1,9 @@ +coder v0.0.0-devel + +USAGE: + coder notifications custom <title> <message> + + Send a custom notification + +——— +Run `coder --help` for a list of global options. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 00478e029e084..2a7f6d2c342e0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1673,6 +1673,54 @@ const docTemplate = `{ } } }, + "/notifications/custom": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Send a custom notification", + "operationId": "send-a-custom-notification", + "parameters": [ + { + "description": "Provide a non-empty title or message", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CustomNotification" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "500": { + "description": "Failed to send custom notification", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/dispatch-methods": { "get": { "security": [ @@ -12451,6 +12499,17 @@ const docTemplate = `{ "CryptoKeyFeatureTailnetResume" ] }, + "codersdk.CustomNotification": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "codersdk.CustomRoleRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3dfa9fdf9792d..89ac9c506eefb 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1456,6 +1456,48 @@ } } }, + "/notifications/custom": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Send a custom notification", + "operationId": "send-a-custom-notification", + "parameters": [ + { + "description": "Provide a non-empty title or message", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CustomNotification" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "500": { + "description": "Failed to send custom notification", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/dispatch-methods": { "get": { "security": [ @@ -11106,6 +11148,17 @@ "CryptoKeyFeatureTailnetResume" ] }, + "codersdk.CustomNotification": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "codersdk.CustomRoleRequest": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 7b30b20c74cce..2ff4d06626451 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1581,6 +1581,7 @@ func New(options *Options) *API { }) r.Get("/dispatch-methods", api.notificationDispatchMethods) r.Post("/test", api.postTestNotification) + r.Post("/custom", api.postCustomNotification) }) r.Route("/tailnet", func(r chi.Router) { r.Use(apiKeyMiddleware) diff --git a/coderd/database/migrations/000367_add_custom_notifications.down.sql b/coderd/database/migrations/000367_add_custom_notifications.down.sql new file mode 100644 index 0000000000000..be5830eb6f15c --- /dev/null +++ b/coderd/database/migrations/000367_add_custom_notifications.down.sql @@ -0,0 +1,2 @@ +DELETE FROM notification_templates WHERE id = '39b1e189-c857-4b0c-877a-511144c18516'; + diff --git a/coderd/database/migrations/000367_add_custom_notifications.up.sql b/coderd/database/migrations/000367_add_custom_notifications.up.sql new file mode 100644 index 0000000000000..5e7c13900bb86 --- /dev/null +++ b/coderd/database/migrations/000367_add_custom_notifications.up.sql @@ -0,0 +1,21 @@ +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) VALUES ( + '39b1e189-c857-4b0c-877a-511144c18516', + 'Custom Notification', + '{{.Labels.custom_title}}', + '{{.Labels.custom_message}}', + '[]', + 'Custom Events', + NULL, + 'system'::notification_template_kind, + true +); diff --git a/coderd/notifications.go b/coderd/notifications.go index 670f3625f41bc..efebed4eb23c4 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "net/http" + "strings" "github.com/google/uuid" @@ -323,6 +324,62 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R httpapi.Write(ctx, rw, http.StatusOK, out) } +// @Summary Send a custom notification +// @ID send-a-custom-notification +// @Security CoderSessionToken +// @Tags Notifications +// @Accept json +// @Produce json +// @Param request body codersdk.CustomNotification true "Provide a non-empty title or message" +// @Success 204 "No Content" +// @Failure 400 {object} codersdk.Response "Invalid request body" +// @Failure 500 {object} codersdk.Response "Failed to send custom notification" +// @Router /notifications/custom [post] +func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + apiKey = httpmw.APIKey(r) + ) + + // Parse request + var req codersdk.CustomNotification + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // Require at least one non-empty field + if strings.TrimSpace(req.Title) == "" && strings.TrimSpace(req.Message) == "" { + api.Logger.Error(ctx, "send custom notification: invalid request body") + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request body", + Detail: "Provide a non-empty 'title' or 'message'.", + }) + return + } + + userID := apiKey.UserID + if _, err := api.NotificationsEnqueuer.Enqueue( + //nolint:gocritic // We need to be notifier to send the notification. + dbauthz.AsNotifier(ctx), + userID, + notifications.TemplateCustomNotification, + map[string]string{ + "custom_title": req.Title, + "custom_message": req.Message, + }, + userID.String(), + ); err != nil { + api.Logger.Error(ctx, "send custom notification", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to send custom notification", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate) { for _, tmpl := range in { out = append(out, codersdk.NotificationTemplate{ diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 0e88361b56f68..9c92a1a622deb 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -49,5 +49,6 @@ var ( // Notification-related events. var ( - TemplateTestNotification = uuid.MustParse("c425f63e-716a-4bf4-ae24-78348f706c3f") + TemplateTestNotification = uuid.MustParse("c425f63e-716a-4bf4-ae24-78348f706c3f") + TemplateCustomNotification = uuid.MustParse("39b1e189-c857-4b0c-877a-511144c18516") ) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index f5e72a8327d7e..c3aa1fd72c4b3 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1258,6 +1258,20 @@ func TestNotificationTemplates_Golden(t *testing.T) { Data: map[string]any{}, }, }, + { + name: "TemplateCustomNotification", + id: notifications.TemplateCustomNotification, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "custom_title": "Custom Title", + "custom_message": "Custom Message", + }, + Data: map[string]any{}, + }, + }, } // We must have a test case for every notification_template. This is enforced below: diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateCustomNotification.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateCustomNotification.html.golden new file mode 100644 index 0000000000000..bf749a4b9ce42 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateCustomNotification.html.golden @@ -0,0 +1,68 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Custom Title +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +Custom Message + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + +<!doctype html> +<html lang=3D"en"> + <head> + <meta charset=3D"UTF-8" /> + <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= +=3D1.0" /> + <title>Custom Title + + +
+
+ 3D"Cod= +
+

+ Custom Title +

+
+

Hi Bobby,

+

Custom Message

+
+
+ =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateCustomNotification.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateCustomNotification.json.golden new file mode 100644 index 0000000000000..66aba4bfbbce5 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateCustomNotification.json.golden @@ -0,0 +1,24 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Custom Notification", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [], + "labels": { + "custom_message": "Custom Message", + "custom_title": "Custom Title" + }, + "data": {}, + "targets": null + }, + "title": "Custom Title", + "title_markdown": "Custom Title", + "body": "Custom Message", + "body_markdown": "Custom Message" +} \ No newline at end of file diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index bae8b8827fe79..11a97f5cef7c9 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -376,3 +376,68 @@ func TestNotificationTest(t *testing.T) { require.Len(t, sent, 0) }) } + +func TestCustomNotification(t *testing.T) { + t.Parallel() + + t.Run("BadRequest", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + ownerClient := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A member user + ownerUser := coderdtest.CreateFirstUser(t, ownerClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) + + // When: The member user attempts to send a custom notification with empty title and message + err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotification{ + Title: "", + Message: "", + }) + + // Then: a bad request error is expected with no notifications sent + 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 body", sdkError.Message) + }) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + ownerClient := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A member user + ownerUser := coderdtest.CreateFirstUser(t, ownerClient) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) + + // When: The member user attempts to send a custom notification + err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotification{ + Title: "Custom Title", + Message: "Custom Message", + }) + require.NoError(t, err) + + // Then: we expect a custom notification to be sent to the member user + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateCustomNotification)) + require.Len(t, sent, 1) + require.Equal(t, memberUser.ID, sent[0].UserID) + require.Len(t, sent[0].Labels, 2) + require.Equal(t, "Custom Title", sent[0].Labels["custom_title"]) + require.Equal(t, "Custom Message", sent[0].Labels["custom_message"]) + require.Equal(t, memberUser.ID.String(), sent[0].CreatedBy) + }) +} diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 9d68c5a01d9c6..7158f8e811d34 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -280,3 +280,21 @@ func (c *Client) PostTestWebpushMessage(ctx context.Context) error { } return nil } + +type CustomNotification struct { + Title string `json:"title"` + Message string `json:"message"` +} + +func (c *Client) PostCustomNotification(ctx context.Context, req CustomNotification) error { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/notifications/custom", req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/admin/monitoring/notifications/index.md b/docs/admin/monitoring/notifications/index.md index 70279dcb16bf1..bb1418ef95736 100644 --- a/docs/admin/monitoring/notifications/index.md +++ b/docs/admin/monitoring/notifications/index.md @@ -143,9 +143,11 @@ After setting the required fields above: ```text CODER_EMAIL_SMARTHOST=smtp.gmail.com:465 CODER_EMAIL_AUTH_USERNAME=@ - CODER_EMAIL_AUTH_PASSWORD="" + CODER_EMAIL_AUTH_PASSWORD="" ``` + **Note:** The `CODER_EMAIL_AUTH_PASSWORD` must be entered without spaces. + See [this help article from Google](https://support.google.com/a/answer/176600?hl=en) for more options. @@ -261,6 +263,39 @@ Administrators can configure which delivery methods are used for each different You can find this page under `https://$CODER_ACCESS_URL/deployment/notifications?tab=events`. +## Custom notifications + +Custom notifications let you send an ad‑hoc notification to yourself using the Coder CLI. +These are useful for surfacing the result of long-running tasks or important state changes. +At this time, custom notifications can only be sent to the user making the request. + +To send a custom notification, execute [`coder notifications custom <message>`](../../../reference/cli/notifications_custom.md). + +**Note:** The recipient is always the requesting user as targeting other users or groups isn’t supported yet. + +### Examples + +- Send yourself a quick update: + +```shell +coder templates push -y && coder notifications custom "Template push complete" "Template version uploaded." +``` + +- Use in a script after a long-running task: + +```shell +#!/usr/bin/env bash +set -o pipefail + +if make test 2>&1 | tee test_output.log; then + coder notifications custom "Tests Succeeded" $'Test results:\n • ✅ success' +else + failures=$(grep -Po '\d+(?=\s+failures)' test_output.log | tail -n1 || echo 0) + coder notifications custom "Tests Failed" $'Test results:\n • ❌ failed ('"$failures"' tests failed)' + exit 1 +fi +``` + ## Stop sending notifications Administrators may wish to stop _all_ notifications across the deployment. We diff --git a/docs/manifest.json b/docs/manifest.json index 9359fb6f1da33..a75ff6459ee5a 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1287,6 +1287,11 @@ "description": "Manage Coder notifications", "path": "reference/cli/notifications.md" }, + { + "title": "notifications custom", + "description": "Send a custom notification", + "path": "reference/cli/notifications_custom.md" + }, { "title": "notifications pause", "description": "Pause notifications", diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index 09890d3b17864..577242edefb6e 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -1,5 +1,61 @@ # Notifications +## Send a custom notification + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/notifications/custom \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /notifications/custom` + +> Body parameter + +```json +{ + "message": "string", + "title": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|----------------------------------------------------------------------|----------|--------------------------------------| +| `body` | body | [codersdk.CustomNotification](schemas.md#codersdkcustomnotification) | true | Provide a non-empty title or message | + +### Example responses + +> 400 Response + +```json +{ + "detail": "string", + "message": "string", + "validations": [ + { + "detail": "string", + "field": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|----------------------------------------------------------------------------|------------------------------------|--------------------------------------------------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | +| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Invalid request body | [codersdk.Response](schemas.md#codersdkresponse) | +| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | Failed to send custom notification | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get notification dispatch methods ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 99e852b3fe4b9..b47fa0b4585aa 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1872,6 +1872,22 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `oidc_convert` | | `tailnet_resume` | +## codersdk.CustomNotification + +```json +{ + "message": "string", + "title": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|--------|----------|--------------|-------------| +| `message` | string | false | | | +| `title` | string | false | | | + ## codersdk.CustomRoleRequest ```json diff --git a/docs/reference/cli/notifications.md b/docs/reference/cli/notifications.md index 14642fd8ddb9f..b8ef843a6a6c3 100644 --- a/docs/reference/cli/notifications.md +++ b/docs/reference/cli/notifications.md @@ -19,7 +19,7 @@ coder notifications Administrators can use these commands to change notification settings. - Pause Coder notifications. Administrators can temporarily stop notifiers from dispatching messages in case of the target outage (for example: unavailable SMTP -server or Webhook not responding).: +server or Webhook not responding): $ coder notifications pause @@ -28,15 +28,21 @@ server or Webhook not responding).: $ coder notifications resume - Send a test notification. Administrators can use this to verify the notification -target settings.: +target settings: $ coder notifications test + + - Send a custom notification to the requesting user. The recipient is always the +requesting user as targeting other users or groups isn’t supported yet: + + $ coder notifications custom "Custom Title" "Custom Message" ``` ## Subcommands -| Name | Purpose | -|--------------------------------------------------|--------------------------| -| [<code>pause</code>](./notifications_pause.md) | Pause notifications | -| [<code>resume</code>](./notifications_resume.md) | Resume notifications | -| [<code>test</code>](./notifications_test.md) | Send a test notification | +| Name | Purpose | +|--------------------------------------------------|----------------------------| +| [<code>pause</code>](./notifications_pause.md) | Pause notifications | +| [<code>resume</code>](./notifications_resume.md) | Resume notifications | +| [<code>test</code>](./notifications_test.md) | Send a test notification | +| [<code>custom</code>](./notifications_custom.md) | Send a custom notification | diff --git a/docs/reference/cli/notifications_custom.md b/docs/reference/cli/notifications_custom.md new file mode 100644 index 0000000000000..9b8eff39fc9c8 --- /dev/null +++ b/docs/reference/cli/notifications_custom.md @@ -0,0 +1,10 @@ +<!-- DO NOT EDIT | GENERATED CONTENT --> +# notifications custom + +Send a custom notification + +## Usage + +```console +coder notifications custom <title> <message> +``` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 98540df857671..f68ae611613ff 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -629,6 +629,12 @@ export const CryptoKeyFeatures: CryptoKeyFeature[] = [ "workspace_apps_token", ]; +// From codersdk/notifications.go +export interface CustomNotification { + readonly title: string; + readonly message: string; +} + // From codersdk/roles.go export interface CustomRoleRequest { readonly name: string; From 327ec0d1929ab104a67b179253110c816fea1241 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira <susana@coder.com> Date: Wed, 10 Sep 2025 13:54:46 +0000 Subject: [PATCH 02/10] fix: block system users from sending custom notifications --- cli/notifications_test.go | 45 +++++++++++++++++++++++++++-- coderd/apidoc/docs.go | 6 ++++ coderd/apidoc/swagger.json | 6 ++++ coderd/database/modelmethods.go | 4 +++ coderd/notifications.go | 26 +++++++++++++++-- coderd/notifications_test.go | 40 +++++++++++++++++++++++++ docs/reference/api/notifications.md | 11 +++---- 7 files changed, 127 insertions(+), 11 deletions(-) diff --git a/cli/notifications_test.go b/cli/notifications_test.go index 5b3dba74bf038..2c5dd85c168bc 100644 --- a/cli/notifications_test.go +++ b/cli/notifications_test.go @@ -10,6 +10,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/notifications" @@ -175,11 +178,12 @@ func TestCustomNotifications(t *testing.T) { notifyEnq := ¬ificationstest.FakeEnqueuer{} - // Given: A member user ownerClient := coderdtest.New(t, &coderdtest.Options{ DeploymentValues: coderdtest.DeploymentValues(t), NotificationsEnqueuer: notifyEnq, }) + + // Given: A member user ownerUser := coderdtest.CreateFirstUser(t, ownerClient) memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) @@ -192,23 +196,58 @@ func TestCustomNotifications(t *testing.T) { var sdkError *codersdk.Error require.Error(t, err) require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") - assert.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) require.Equal(t, "Invalid request body", sdkError.Message) sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification)) require.Len(t, sent, 0) }) + t.Run("SystemUserNotAllowed", func(t *testing.T) { + t.Parallel() + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A system user (prebuilds system user) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: database.PrebuildsSystemUserID, + LoginType: database.LoginTypeNone, + }) + systemUserClient := codersdk.New(ownerClient.URL) + systemUserClient.SetSessionToken(token) + + // When: The system user attempts to send a custom notification + inv, root := clitest.New(t, "notifications", "custom", "Custom Title", "Custom Message") + clitest.SetupConfig(t, systemUserClient, root) + + // Then: an error is expected with no notifications sent + err := inv.Run() + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusForbidden, sdkError.StatusCode()) + require.Equal(t, "Forbidden", sdkError.Message) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification)) + require.Len(t, sent, 0) + }) + t.Run("Success", func(t *testing.T) { t.Parallel() notifyEnq := ¬ificationstest.FakeEnqueuer{} - // Given: A member user ownerClient := coderdtest.New(t, &coderdtest.Options{ DeploymentValues: coderdtest.DeploymentValues(t), NotificationsEnqueuer: notifyEnq, }) + + // Given: A member user ownerUser := coderdtest.CreateFirstUser(t, ownerClient) memberClient, memberUser := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2a7f6d2c342e0..5186552c585eb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1712,6 +1712,12 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.Response" } }, + "403": { + "description": "System users cannot send custom notifications", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, "500": { "description": "Failed to send custom notification", "schema": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 89ac9c506eefb..4c118ec5f2e85 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1489,6 +1489,12 @@ "$ref": "#/definitions/codersdk.Response" } }, + "403": { + "description": "System users cannot send custom notifications", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, "500": { "description": "Failed to send custom notification", "schema": { diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index e080c7d7e4217..42a771bcd598b 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -376,6 +376,10 @@ func (u User) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.ID) } +func (u User) IsSystemUser() bool { + return u.IsSystem +} + func (u GetUsersRow) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.ID) } diff --git a/coderd/notifications.go b/coderd/notifications.go index efebed4eb23c4..1b3b4dc53d56c 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -333,6 +333,7 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R // @Param request body codersdk.CustomNotification true "Provide a non-empty title or message" // @Success 204 "No Content" // @Failure 400 {object} codersdk.Response "Invalid request body" +// @Failure 403 {object} codersdk.Response "System users cannot send custom notifications" // @Failure 500 {object} codersdk.Response "Failed to send custom notification" // @Router /notifications/custom [post] func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) { @@ -357,17 +358,36 @@ func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) return } - userID := apiKey.UserID + // Block system users from sending custom notifications + user, err := api.Database.GetUserByID(ctx, apiKey.UserID) + if err != nil { + api.Logger.Error(ctx, "send custom notification", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to send custom notification", + Detail: err.Error(), + }) + return + } + if user.IsSystemUser() { + api.Logger.Error(ctx, "send custom notification: system user is not allowed", + slog.F("id", user.ID.String()), slog.F("name", user.Name)) + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Forbidden", + Detail: "System users cannot send custom notifications.", + }) + return + } + if _, err := api.NotificationsEnqueuer.Enqueue( //nolint:gocritic // We need to be notifier to send the notification. dbauthz.AsNotifier(ctx), - userID, + user.ID, notifications.TemplateCustomNotification, map[string]string{ "custom_title": req.Title, "custom_message": req.Message, }, - userID.String(), + user.ID.String(), ); err != nil { api.Logger.Error(ctx, "send custom notification", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 11a97f5cef7c9..34d8ea87c7756 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -11,6 +11,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/codersdk" @@ -407,6 +408,45 @@ func TestCustomNotification(t *testing.T) { require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) require.Equal(t, "Invalid request body", sdkError.Message) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification)) + require.Len(t, sent, 0) + }) + + t.Run("SystemUserNotAllowed", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + NotificationsEnqueuer: notifyEnq, + }) + + // Given: A system user (prebuilds system user) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: database.PrebuildsSystemUserID, + LoginType: database.LoginTypeNone, + }) + systemUserClient := codersdk.New(ownerClient.URL) + systemUserClient.SetSessionToken(token) + + // When: The system user attempts to send a custom notification + err := systemUserClient.PostCustomNotification(ctx, codersdk.CustomNotification{ + Title: "Custom Title", + Message: "Custom Message", + }) + + // Then: a forbidden error is expected with no notifications sent + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusForbidden, sdkError.StatusCode()) + require.Equal(t, "Forbidden", sdkError.Message) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification)) + require.Len(t, sent, 0) }) t.Run("Success", func(t *testing.T) { diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index 577242edefb6e..bc385deedee80 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -48,11 +48,12 @@ curl -X POST http://coder-server:8080/api/v2/notifications/custom \ ### Responses -| Status | Meaning | Description | Schema | -|--------|----------------------------------------------------------------------------|------------------------------------|--------------------------------------------------| -| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | -| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Invalid request body | [codersdk.Response](schemas.md#codersdkresponse) | -| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | Failed to send custom notification | [codersdk.Response](schemas.md#codersdkresponse) | +| Status | Meaning | Description | Schema | +|--------|----------------------------------------------------------------------------|-----------------------------------------------|--------------------------------------------------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | +| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Invalid request body | [codersdk.Response](schemas.md#codersdkresponse) | +| 403 | [Forbidden](https://tools.ietf.org/html/rfc7231#section-6.5.3) | System users cannot send custom notifications | [codersdk.Response](schemas.md#codersdkresponse) | +| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | Failed to send custom notification | [codersdk.Response](schemas.md#codersdkresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). From 0d1144a0e7e58ee13f2dbad36997a3b22d38d1dd Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira <susana@coder.com> Date: Wed, 10 Sep 2025 13:56:41 +0000 Subject: [PATCH 03/10] fix: cli/notificatins_test.go imports --- cli/notifications_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/notifications_test.go b/cli/notifications_test.go index 2c5dd85c168bc..f5618d33c8aba 100644 --- a/cli/notifications_test.go +++ b/cli/notifications_test.go @@ -10,11 +10,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/codersdk" From 203668d9ecccc93990c666dae2bb684729615fba Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira <susana@coder.com> Date: Wed, 10 Sep 2025 14:47:43 +0000 Subject: [PATCH 04/10] chore: add kind 'custom' type to notification template kind enum --- coderd/apidoc/docs.go | 40 +++++++++++ coderd/apidoc/swagger.json | 36 ++++++++++ coderd/coderd.go | 1 + coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dump.sql | 3 +- .../000367_add_custom_notifications.down.sql | 13 ++++ .../000367_add_custom_notifications.up.sql | 19 +++++- coderd/database/models.go | 5 +- coderd/notifications.go | 39 ++++++++--- docs/reference/api/notifications.md | 66 ++++++++++++++++++- 10 files changed, 208 insertions(+), 18 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5186552c585eb..5a426ee503450 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1980,6 +1980,40 @@ const docTemplate = `{ } } }, + "/notifications/templates/custom": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get custom notification templates", + "operationId": "get-custom-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + }, + "500": { + "description": "Failed to retrieve 'custom' notifications template", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/templates/system": { "get": { "security": [ @@ -2004,6 +2038,12 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.NotificationTemplate" } } + }, + "500": { + "description": "Failed to retrieve 'system' notifications template", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4c118ec5f2e85..103c8f48b9043 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1726,6 +1726,36 @@ } } }, + "/notifications/templates/custom": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Get custom notification templates", + "operationId": "get-custom-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + }, + "500": { + "description": "Failed to retrieve 'custom' notifications template", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/notifications/templates/system": { "get": { "security": [ @@ -1746,6 +1776,12 @@ "$ref": "#/definitions/codersdk.NotificationTemplate" } } + }, + "500": { + "description": "Failed to retrieve 'system' notifications template", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } } } } diff --git a/coderd/coderd.go b/coderd/coderd.go index 2ff4d06626451..6f80286395eb8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1578,6 +1578,7 @@ func New(options *Options) *API { r.Put("/settings", api.putNotificationsSettings) r.Route("/templates", func(r chi.Router) { r.Get("/system", api.systemNotificationTemplates) + r.Get("/custom", api.customNotificationTemplates) }) r.Get("/dispatch-methods", api.notificationDispatchMethods) r.Post("/test", api.postTestNotification) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2486c8e715f3c..f746b9f8d69a5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2281,8 +2281,8 @@ func (q *querier) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) } func (q *querier) GetNotificationTemplatesByKind(ctx context.Context, kind database.NotificationTemplateKind) ([]database.NotificationTemplate, error) { - // Anyone can read the system notification templates. - if kind == database.NotificationTemplateKindSystem { + // Anyone can read the 'system' and 'custom' notification templates. + if kind == database.NotificationTemplateKindSystem || kind == database.NotificationTemplateKindCustom { return q.db.GetNotificationTemplatesByKind(ctx, kind) } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b9d782486eb89..7daa2426a4a37 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -150,7 +150,8 @@ CREATE TYPE notification_method AS ENUM ( ); CREATE TYPE notification_template_kind AS ENUM ( - 'system' + 'system', + 'custom' ); CREATE TYPE parameter_destination_scheme AS ENUM ( diff --git a/coderd/database/migrations/000367_add_custom_notifications.down.sql b/coderd/database/migrations/000367_add_custom_notifications.down.sql index be5830eb6f15c..95f1a94bdb526 100644 --- a/coderd/database/migrations/000367_add_custom_notifications.down.sql +++ b/coderd/database/migrations/000367_add_custom_notifications.down.sql @@ -1,2 +1,15 @@ +-- Remove Custom Notification template DELETE FROM notification_templates WHERE id = '39b1e189-c857-4b0c-877a-511144c18516'; +-- Recreate the old enum without 'custom' +CREATE TYPE old_notification_template_kind AS ENUM ('system'); + +-- Update notification_templates to use the old enum +ALTER TABLE notification_templates + ALTER COLUMN kind DROP DEFAULT, + ALTER COLUMN kind TYPE old_notification_template_kind USING (kind::text::old_notification_template_kind), + ALTER COLUMN kind SET DEFAULT 'system'::old_notification_template_kind; + +-- Drop the current enum and restore the original name +DROP TYPE notification_template_kind; +ALTER TYPE old_notification_template_kind RENAME TO notification_template_kind; diff --git a/coderd/database/migrations/000367_add_custom_notifications.up.sql b/coderd/database/migrations/000367_add_custom_notifications.up.sql index 5e7c13900bb86..f6fe12f80915d 100644 --- a/coderd/database/migrations/000367_add_custom_notifications.up.sql +++ b/coderd/database/migrations/000367_add_custom_notifications.up.sql @@ -1,3 +1,20 @@ +-- Create new enum with 'custom' value +CREATE TYPE new_notification_template_kind AS ENUM ( + 'system', + 'custom' +); + +-- Update the notification_templates table to use new enum +ALTER TABLE notification_templates + ALTER COLUMN kind DROP DEFAULT, + ALTER COLUMN kind TYPE new_notification_template_kind USING (kind::text::new_notification_template_kind), + ALTER COLUMN kind SET DEFAULT 'system'::new_notification_template_kind; + +-- Drop old enum and rename new one +DROP TYPE notification_template_kind; +ALTER TYPE new_notification_template_kind RENAME TO notification_template_kind; + +-- Insert new Custom Notification template with 'custom' kind INSERT INTO notification_templates ( id, name, @@ -16,6 +33,6 @@ INSERT INTO notification_templates ( '[]', 'Custom Events', NULL, - 'system'::notification_template_kind, + 'custom'::notification_template_kind, true ); diff --git a/coderd/database/models.go b/coderd/database/models.go index 625730ebb79a3..78375b9896407 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1201,6 +1201,7 @@ type NotificationTemplateKind string const ( NotificationTemplateKindSystem NotificationTemplateKind = "system" + NotificationTemplateKindCustom NotificationTemplateKind = "custom" ) func (e *NotificationTemplateKind) Scan(src interface{}) error { @@ -1240,7 +1241,8 @@ func (ns NullNotificationTemplateKind) Value() (driver.Value, error) { func (e NotificationTemplateKind) Valid() bool { switch e { - case NotificationTemplateKindSystem: + case NotificationTemplateKindSystem, + NotificationTemplateKindCustom: return true } return false @@ -1249,6 +1251,7 @@ func (e NotificationTemplateKind) Valid() bool { func AllNotificationTemplateKindValues() []NotificationTemplateKind { return []NotificationTemplateKind{ NotificationTemplateKindSystem, + NotificationTemplateKindCustom, } } diff --git a/coderd/notifications.go b/coderd/notifications.go index 1b3b4dc53d56c..6f68bfccd80c1 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -3,6 +3,7 @@ package coderd import ( "bytes" "encoding/json" + "fmt" "net/http" "strings" @@ -125,20 +126,14 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request 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) { +// notificationTemplatesByKind gets the notification templates by kind +func (api *API) notificationTemplatesByKind(rw http.ResponseWriter, r *http.Request, kind database.NotificationTemplateKind) { ctx := r.Context() - templates, err := api.Database.GetNotificationTemplatesByKind(ctx, database.NotificationTemplateKindSystem) + templates, err := api.Database.GetNotificationTemplatesByKind(ctx, kind) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to retrieve system notifications templates.", + Message: fmt.Sprintf("Failed to retrieve '%s' notifications templates.", kind), Detail: err.Error(), }) return @@ -148,6 +143,30 @@ func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Requ httpapi.Write(r.Context(), rw, http.StatusOK, out) } +// @Summary Get system notification templates +// @ID get-system-notification-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Success 200 {array} codersdk.NotificationTemplate +// @Failure 500 {object} codersdk.Response "Failed to retrieve 'system' notifications template" +// @Router /notifications/templates/system [get] +func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { + api.notificationTemplatesByKind(rw, r, database.NotificationTemplateKindSystem) +} + +// @Summary Get custom notification templates +// @ID get-custom-notification-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Success 200 {array} codersdk.NotificationTemplate +// @Failure 500 {object} codersdk.Response "Failed to retrieve 'custom' notifications template" +// @Router /notifications/templates/custom [get] +func (api *API) customNotificationTemplates(rw http.ResponseWriter, r *http.Request) { + api.notificationTemplatesByKind(rw, r, database.NotificationTemplateKindCustom) +} + // @Summary Get notification dispatch methods // @ID get-notification-dispatch-methods // @Security CoderSessionToken diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index bc385deedee80..370dbd7102ba5 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -372,6 +372,65 @@ curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get custom notification templates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/templates/custom \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/templates/custom` + +### Example responses + +> 200 Response + +```json +[ + { + "actions": "string", + "body_template": "string", + "enabled_by_default": true, + "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) | +| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | Failed to retrieve 'custom' notifications template | [codersdk.Response](schemas.md#codersdkresponse) | + +<h3 id="get-custom-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 | | | +| `» enabled_by_default` | boolean | false | | | +| `» group` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» kind` | string | false | | | +| `» method` | string | false | | | +| `» name` | string | false | | | +| `» title_template` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get system notification templates ### Code samples @@ -407,9 +466,10 @@ curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \ ### 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) | +| Status | Meaning | Description | Schema | +|--------|----------------------------------------------------------------------------|----------------------------------------------------|-----------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationTemplate](schemas.md#codersdknotificationtemplate) | +| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | Failed to retrieve 'system' notifications template | [codersdk.Response](schemas.md#codersdkresponse) | <h3 id="get-system-notification-templates-responseschema">Response Schema</h3> From d3ffd1accb6ab36a0b82ee5c4cd32e63710b0d8b Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira <susana@coder.com> Date: Wed, 10 Sep 2025 15:23:09 +0000 Subject: [PATCH 05/10] chore: update custom notification request with a content object --- cli/notifications.go | 8 +++++--- coderd/apidoc/docs.go | 12 ++++++++++-- coderd/apidoc/swagger.json | 12 ++++++++++-- coderd/notifications.go | 17 ++++++++--------- coderd/notifications_test.go | 24 +++++++++++++++--------- codersdk/notifications.go | 21 +++++++++++++++++++-- docs/reference/api/notifications.md | 12 +++++++----- docs/reference/api/schemas.md | 19 ++++++++++++++++++- site/src/api/typesGenerated.ts | 7 ++++++- 9 files changed, 98 insertions(+), 34 deletions(-) diff --git a/cli/notifications.go b/cli/notifications.go index 3f146a37148b3..ecc8cfc3c8541 100644 --- a/cli/notifications.go +++ b/cli/notifications.go @@ -125,9 +125,11 @@ func (r *RootCmd) customNotifications() *serpent.Command { r.InitClient(client), ), Handler: func(inv *serpent.Invocation) error { - err := client.PostCustomNotification(inv.Context(), codersdk.CustomNotification{ - Title: inv.Args[0], - Message: inv.Args[1], + err := client.PostCustomNotification(inv.Context(), codersdk.CustomNotificationRequest{ + Content: &codersdk.CustomNotificationContent{ + Title: inv.Args[0], + Message: inv.Args[1], + }, }) if err != nil { return xerrors.Errorf("unable to post custom notification: %w", err) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5a426ee503450..cf227f90f0bc0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1698,7 +1698,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CustomNotification" + "$ref": "#/definitions/codersdk.CustomNotificationRequest" } } ], @@ -12545,7 +12545,7 @@ const docTemplate = `{ "CryptoKeyFeatureTailnetResume" ] }, - "codersdk.CustomNotification": { + "codersdk.CustomNotificationContent": { "type": "object", "properties": { "message": { @@ -12556,6 +12556,14 @@ const docTemplate = `{ } } }, + "codersdk.CustomNotificationRequest": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/codersdk.CustomNotificationContent" + } + } + }, "codersdk.CustomRoleRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 103c8f48b9043..347733ea84420 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1475,7 +1475,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CustomNotification" + "$ref": "#/definitions/codersdk.CustomNotificationRequest" } } ], @@ -11190,7 +11190,7 @@ "CryptoKeyFeatureTailnetResume" ] }, - "codersdk.CustomNotification": { + "codersdk.CustomNotificationContent": { "type": "object", "properties": { "message": { @@ -11201,6 +11201,14 @@ } } }, + "codersdk.CustomNotificationRequest": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/codersdk.CustomNotificationContent" + } + } + }, "codersdk.CustomRoleRequest": { "type": "object", "properties": { diff --git a/coderd/notifications.go b/coderd/notifications.go index 6f68bfccd80c1..5d3cd118acce3 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "github.com/google/uuid" @@ -349,7 +348,7 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R // @Tags Notifications // @Accept json // @Produce json -// @Param request body codersdk.CustomNotification true "Provide a non-empty title or message" +// @Param request body codersdk.CustomNotificationRequest true "Provide a non-empty title or message" // @Success 204 "No Content" // @Failure 400 {object} codersdk.Response "Invalid request body" // @Failure 403 {object} codersdk.Response "System users cannot send custom notifications" @@ -362,17 +361,17 @@ func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) ) // Parse request - var req codersdk.CustomNotification + var req codersdk.CustomNotificationRequest if !httpapi.Read(ctx, rw, r, &req) { return } - // Require at least one non-empty field - if strings.TrimSpace(req.Title) == "" && strings.TrimSpace(req.Message) == "" { - api.Logger.Error(ctx, "send custom notification: invalid request body") + // Validate request: require `content` and at least one non-empty `title` or `message`. + if err := req.Validate(); err != nil { + api.Logger.Error(ctx, "send custom notification: validation failed", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid request body", - Detail: "Provide a non-empty 'title' or 'message'.", + Detail: err.Error(), }) return } @@ -403,8 +402,8 @@ func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) user.ID, notifications.TemplateCustomNotification, map[string]string{ - "custom_title": req.Title, - "custom_message": req.Message, + "custom_title": req.Content.Title, + "custom_message": req.Content.Message, }, user.ID.String(), ); err != nil { diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 34d8ea87c7756..f1a081b3e8a89 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -397,9 +397,11 @@ func TestCustomNotification(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) // When: The member user attempts to send a custom notification with empty title and message - err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotification{ - Title: "", - Message: "", + err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotificationRequest{ + Content: &codersdk.CustomNotificationContent{ + Title: "", + Message: "", + }, }) // Then: a bad request error is expected with no notifications sent @@ -433,9 +435,11 @@ func TestCustomNotification(t *testing.T) { systemUserClient.SetSessionToken(token) // When: The system user attempts to send a custom notification - err := systemUserClient.PostCustomNotification(ctx, codersdk.CustomNotification{ - Title: "Custom Title", - Message: "Custom Message", + err := systemUserClient.PostCustomNotification(ctx, codersdk.CustomNotificationRequest{ + Content: &codersdk.CustomNotificationContent{ + Title: "Custom Title", + Message: "Custom Message", + }, }) // Then: a forbidden error is expected with no notifications sent @@ -465,9 +469,11 @@ func TestCustomNotification(t *testing.T) { memberClient, memberUser := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID) // When: The member user attempts to send a custom notification - err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotification{ - Title: "Custom Title", - Message: "Custom Message", + err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotificationRequest{ + Content: &codersdk.CustomNotificationContent{ + Title: "Custom Title", + Message: "Custom Message", + }, }) require.NoError(t, err) diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 7158f8e811d34..df65e4e8e01bf 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/google/uuid" @@ -281,12 +282,28 @@ func (c *Client) PostTestWebpushMessage(ctx context.Context) error { return nil } -type CustomNotification struct { +type CustomNotificationContent struct { Title string `json:"title"` Message string `json:"message"` } -func (c *Client) PostCustomNotification(ctx context.Context, req CustomNotification) error { +type CustomNotificationRequest struct { + Content *CustomNotificationContent `json:"content"` + // TODO(ssncferreira): Add target (user_ids, roles) to support multi-user and role-based delivery. +} + +func (c CustomNotificationRequest) Validate() error { + if c.Content == nil { + return xerrors.Errorf("content is required") + } + if strings.TrimSpace(c.Content.Title) == "" && + strings.TrimSpace(c.Content.Message) == "" { + return xerrors.Errorf("provide a non-empty 'content.title' or 'content.message'") + } + return nil +} + +func (c *Client) PostCustomNotification(ctx context.Context, req CustomNotificationRequest) error { res, err := c.Request(ctx, http.MethodPost, "/api/v2/notifications/custom", req) if err != nil { return err diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index 370dbd7102ba5..df94b83c164cb 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -18,16 +18,18 @@ curl -X POST http://coder-server:8080/api/v2/notifications/custom \ ```json { - "message": "string", - "title": "string" + "content": { + "message": "string", + "title": "string" + } } ``` ### Parameters -| Name | In | Type | Required | Description | -|--------|------|----------------------------------------------------------------------|----------|--------------------------------------| -| `body` | body | [codersdk.CustomNotification](schemas.md#codersdkcustomnotification) | true | Provide a non-empty title or message | +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------|----------|--------------------------------------| +| `body` | body | [codersdk.CustomNotificationRequest](schemas.md#codersdkcustomnotificationrequest) | true | Provide a non-empty title or message | ### Example responses diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index b47fa0b4585aa..df4e896f91d7c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1872,7 +1872,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `oidc_convert` | | `tailnet_resume` | -## codersdk.CustomNotification +## codersdk.CustomNotificationContent ```json { @@ -1888,6 +1888,23 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `message` | string | false | | | | `title` | string | false | | | +## codersdk.CustomNotificationRequest + +```json +{ + "content": { + "message": "string", + "title": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|--------------------------------------------------------------------------|----------|--------------|-------------| +| `content` | [codersdk.CustomNotificationContent](#codersdkcustomnotificationcontent) | false | | | + ## codersdk.CustomRoleRequest ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f68ae611613ff..431b438c4cc8c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -630,11 +630,16 @@ export const CryptoKeyFeatures: CryptoKeyFeature[] = [ ]; // From codersdk/notifications.go -export interface CustomNotification { +export interface CustomNotificationContent { readonly title: string; readonly message: string; } +// From codersdk/notifications.go +export interface CustomNotificationRequest { + readonly content: CustomNotificationContent | null; +} + // From codersdk/roles.go export interface CustomRoleRequest { readonly name: string; From 938d010fef5e56f9aa82e2b61c33e051a57499d9 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira <susana@coder.com> Date: Wed, 10 Sep 2025 15:27:40 +0000 Subject: [PATCH 06/10] chore: improve cli description --- cli/notifications.go | 2 +- cli/testdata/coder_notifications_--help.golden | 5 ++--- docs/reference/cli/notifications.md | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cli/notifications.go b/cli/notifications.go index ecc8cfc3c8541..a0f155053742b 100644 --- a/cli/notifications.go +++ b/cli/notifications.go @@ -28,7 +28,7 @@ func (r *RootCmd) notifications() *serpent.Command { Command: "coder notifications test", }, Example{ - Description: "Send a custom notification to the requesting user. The recipient is always the requesting user as targeting other users or groups isn’t supported yet", + Description: "Send a custom notification to the requesting user. Sending notifications targeting other users or groups is currently not supported", Command: "coder notifications custom \"Custom Title\" \"Custom Message\"", }, ), diff --git a/cli/testdata/coder_notifications_--help.golden b/cli/testdata/coder_notifications_--help.golden index cfb70e2a9d5fa..5eec2d3bff934 100644 --- a/cli/testdata/coder_notifications_--help.golden +++ b/cli/testdata/coder_notifications_--help.golden @@ -26,9 +26,8 @@ USAGE: $ coder notifications test - - Send a custom notification to the requesting user. The recipient is always - the - requesting user as targeting other users or groups isn’t supported yet: + - Send a custom notification to the requesting user. Sending notifications + targeting other users or groups is currently not supported: $ coder notifications custom "Custom Title" "Custom Message" diff --git a/docs/reference/cli/notifications.md b/docs/reference/cli/notifications.md index b8ef843a6a6c3..bb471754e4958 100644 --- a/docs/reference/cli/notifications.md +++ b/docs/reference/cli/notifications.md @@ -32,8 +32,8 @@ target settings: $ coder notifications test - - Send a custom notification to the requesting user. The recipient is always the -requesting user as targeting other users or groups isn’t supported yet: + - Send a custom notification to the requesting user. Sending notifications +targeting other users or groups is currently not supported: $ coder notifications custom "Custom Title" "Custom Message" ``` From 9a396935b80dd309b8c2595ddd2702bf6a6bfa96 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira <susana@coder.com> Date: Wed, 10 Sep 2025 16:59:42 +0000 Subject: [PATCH 07/10] chore: bypass the per-day notification dedupe --- coderd/notifications.go | 11 ++++++++++- codersdk/notifications.go | 1 + docs/admin/monitoring/notifications/index.md | 4 ++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/coderd/notifications.go b/coderd/notifications.go index 5d3cd118acce3..9f4f8b873c283 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -396,7 +396,7 @@ func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) return } - if _, err := api.NotificationsEnqueuer.Enqueue( + if _, err := api.NotificationsEnqueuer.EnqueueWithData( //nolint:gocritic // We need to be notifier to send the notification. dbauthz.AsNotifier(ctx), user.ID, @@ -405,6 +405,15 @@ func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) "custom_title": req.Content.Title, "custom_message": req.Content.Message, }, + map[string]any{ + // Current dedupe is done via an hash of (template, user, method, payload, targets, day). + // We intentionally include a timestamp to bypass the per-day dedupe so the caller can + // resend identical content to themselves multiple times in one day. + // TODO(ssncferreira): When we support sending custom notifications to multiple users/roles, + // enforce proper deduplication across recipients to reduce noise and prevent spam. + // See https://github.com/coder/coder/issues/19768 + "dedupe_bypass_ts": api.Clock.Now().UTC(), + }, user.ID.String(), ); err != nil { api.Logger.Error(ctx, "send custom notification", slog.Error(err)) diff --git a/codersdk/notifications.go b/codersdk/notifications.go index df65e4e8e01bf..e3171549c70c9 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -290,6 +290,7 @@ type CustomNotificationContent struct { type CustomNotificationRequest struct { Content *CustomNotificationContent `json:"content"` // TODO(ssncferreira): Add target (user_ids, roles) to support multi-user and role-based delivery. + // See: https://github.com/coder/coder/issues/19768 } func (c CustomNotificationRequest) Validate() error { diff --git a/docs/admin/monitoring/notifications/index.md b/docs/admin/monitoring/notifications/index.md index bb1418ef95736..e87a4dd1ac27d 100644 --- a/docs/admin/monitoring/notifications/index.md +++ b/docs/admin/monitoring/notifications/index.md @@ -271,6 +271,10 @@ At this time, custom notifications can only be sent to the user making the reque To send a custom notification, execute [`coder notifications custom <title> <message>`](../../../reference/cli/notifications_custom.md). +<!-- TODO(ssncferreira): Update when sending custom notifications to multiple users/roles is supported. + Explain deduplication behaviour for multiple users/roles. + See: https://github.com/coder/coder/issues/19768 +--> **Note:** The recipient is always the requesting user as targeting other users or groups isn’t supported yet. ### Examples From 80c6b22b7a4e866e5961836b1635fe75fc81673a Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira <susana@coder.com> Date: Wed, 10 Sep 2025 17:09:54 +0000 Subject: [PATCH 08/10] fix: fix migration numbers --- ...ications.down.sql => 000368_add_custom_notifications.down.sql} | 0 ...otifications.up.sql => 000368_add_custom_notifications.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000367_add_custom_notifications.down.sql => 000368_add_custom_notifications.down.sql} (100%) rename coderd/database/migrations/{000367_add_custom_notifications.up.sql => 000368_add_custom_notifications.up.sql} (100%) diff --git a/coderd/database/migrations/000367_add_custom_notifications.down.sql b/coderd/database/migrations/000368_add_custom_notifications.down.sql similarity index 100% rename from coderd/database/migrations/000367_add_custom_notifications.down.sql rename to coderd/database/migrations/000368_add_custom_notifications.down.sql diff --git a/coderd/database/migrations/000367_add_custom_notifications.up.sql b/coderd/database/migrations/000368_add_custom_notifications.up.sql similarity index 100% rename from coderd/database/migrations/000367_add_custom_notifications.up.sql rename to coderd/database/migrations/000368_add_custom_notifications.up.sql From 657a23052008270e899a83ea5590d7219b3f6487 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira <susana@coder.com> Date: Thu, 11 Sep 2025 09:51:08 +0000 Subject: [PATCH 09/10] chore: address comments --- coderd/database/modelmethods.go | 4 ---- coderd/notifications.go | 15 ++++++++------- codersdk/notifications.go | 13 +++++++++++++ site/src/api/typesGenerated.ts | 6 ++++++ 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 42a771bcd598b..e080c7d7e4217 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -376,10 +376,6 @@ func (u User) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.ID) } -func (u User) IsSystemUser() bool { - return u.IsSystem -} - func (u GetUsersRow) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.ID) } diff --git a/coderd/notifications.go b/coderd/notifications.go index 9f4f8b873c283..5a159fa6f3cf4 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" "github.com/google/uuid" @@ -132,7 +133,7 @@ func (api *API) notificationTemplatesByKind(rw http.ResponseWriter, r *http.Requ templates, err := api.Database.GetNotificationTemplatesByKind(ctx, kind) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("Failed to retrieve '%s' notifications templates.", kind), + Message: fmt.Sprintf("Failed to retrieve %q notifications templates.", kind), Detail: err.Error(), }) return @@ -386,7 +387,7 @@ func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) }) return } - if user.IsSystemUser() { + if user.IsSystem { api.Logger.Error(ctx, "send custom notification: system user is not allowed", slog.F("id", user.ID.String()), slog.F("name", user.Name)) httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ @@ -407,12 +408,12 @@ func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) }, map[string]any{ // Current dedupe is done via an hash of (template, user, method, payload, targets, day). - // We intentionally include a timestamp to bypass the per-day dedupe so the caller can - // resend identical content to themselves multiple times in one day. - // TODO(ssncferreira): When we support sending custom notifications to multiple users/roles, + // Include a minute-bucketed timestamp to bypass per-day dedupe for self-sends, + // letting the caller resend identical content the same day (but not more than + // once per minute). + // TODO(ssncferreira): When custom notifications can target multiple users/roles, // enforce proper deduplication across recipients to reduce noise and prevent spam. - // See https://github.com/coder/coder/issues/19768 - "dedupe_bypass_ts": api.Clock.Now().UTC(), + "dedupe_bypass_ts": api.Clock.Now().UTC().Truncate(time.Minute), }, user.ID.String(), ); err != nil { diff --git a/codersdk/notifications.go b/codersdk/notifications.go index e3171549c70c9..76e9e99e6ca2b 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -293,6 +293,11 @@ type CustomNotificationRequest struct { // See: https://github.com/coder/coder/issues/19768 } +const ( + maxCustomNotificationTitleLen = 120 + maxCustomNotificationMessageLen = 2000 +) + func (c CustomNotificationRequest) Validate() error { if c.Content == nil { return xerrors.Errorf("content is required") @@ -301,6 +306,14 @@ func (c CustomNotificationRequest) Validate() error { strings.TrimSpace(c.Content.Message) == "" { return xerrors.Errorf("provide a non-empty 'content.title' or 'content.message'") } + + if len(c.Content.Title) > maxCustomNotificationTitleLen { + return xerrors.Errorf("'content.title' must be less than %d characters", maxCustomNotificationTitleLen) + } + if len(c.Content.Message) > maxCustomNotificationMessageLen { + return xerrors.Errorf("'content.message' must be less than %d characters", maxCustomNotificationMessageLen) + } + return nil } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8d7bef65de233..ccbe5924b5c00 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4140,6 +4140,12 @@ export const annotationSecretKey = "secret"; // From codersdk/insights.go export const insightsTimeLayout = "2006-01-02T15:04:05Z07:00"; +// From codersdk/notifications.go +export const maxCustomNotificationMessageLen = 2000; + +// From codersdk/notifications.go +export const maxCustomNotificationTitleLen = 120; + // From healthsdk/interfaces.go export const safeMTU = 1378; From 681a29ff5c6dae8fcfc751609a3e1d7e0baa0ec6 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira <susana@coder.com> Date: Thu, 11 Sep 2025 10:29:01 +0000 Subject: [PATCH 10/10] chore: require both title and message --- coderd/notifications.go | 2 +- codersdk/notifications.go | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/coderd/notifications.go b/coderd/notifications.go index 5a159fa6f3cf4..e09dd2d69ceca 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -367,7 +367,7 @@ func (api *API) postCustomNotification(rw http.ResponseWriter, r *http.Request) return } - // Validate request: require `content` and at least one non-empty `title` or `message`. + // Validate request: require `content` and non-empty `title` and `message` if err := req.Validate(); err != nil { api.Logger.Error(ctx, "send custom notification: validation failed", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 76e9e99e6ca2b..9128c4cce26e3 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -302,18 +302,20 @@ func (c CustomNotificationRequest) Validate() error { if c.Content == nil { return xerrors.Errorf("content is required") } - if strings.TrimSpace(c.Content.Title) == "" && - strings.TrimSpace(c.Content.Message) == "" { - return xerrors.Errorf("provide a non-empty 'content.title' or 'content.message'") - } + return c.Content.Validate() +} - if len(c.Content.Title) > maxCustomNotificationTitleLen { +func (c CustomNotificationContent) Validate() error { + if strings.TrimSpace(c.Title) == "" || + strings.TrimSpace(c.Message) == "" { + return xerrors.Errorf("provide a non-empty 'content.title' and 'content.message'") + } + if len(c.Title) > maxCustomNotificationTitleLen { return xerrors.Errorf("'content.title' must be less than %d characters", maxCustomNotificationTitleLen) } - if len(c.Content.Message) > maxCustomNotificationMessageLen { + if len(c.Message) > maxCustomNotificationMessageLen { return xerrors.Errorf("'content.message' must be less than %d characters", maxCustomNotificationMessageLen) } - return nil }