Files
coder/coderd/notifications.go
T
Susana Ferreira eec6c8c120 feat: support custom notifications (#19751)
## Description

Adds support for sending an ad‑hoc custom notification to the
authenticated user via API and CLI. This is useful for surfacing the
result of scripts or long‑running tasks. Notifications are delivered
through the configured method and the dashboard Inbox, respecting
existing preferences and delivery settings.

## Changes

* New notification template: “Custom Notification” with a label for a
custom title and a custom message.
* New API endpoint: `POST /api/v2/notifications/custom` to send a custom
notification to the requesting user.
* New API endpoint: `GET /notifications/templates/custom` to get custom
notification template.
* New CLI subcommand: `coder notifications custom <title> <message>` to
send a custom notification to the requesting user.
* Documentation updates: Add a “Custom notifications” section under
Administration > Monitoring > Notifications, including instructions on
sending custom notifications and examples of when to use them.

Closes: https://github.com/coder/coder/issues/19611
2025-09-11 15:08:57 +02:00

460 lines
15 KiB
Go

package coderd
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"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/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)
// @Summary Get notifications settings
// @ID get-notifications-settings
// @Security CoderSessionToken
// @Produce json
// @Tags Notifications
// @Success 200 {object} codersdk.NotificationsSettings
// @Router /notifications/settings [get]
func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) {
settingsJSON, err := api.Database.GetNotificationsSettings(r.Context())
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch current notifications settings.",
Detail: err.Error(),
})
return
}
var settings codersdk.NotificationsSettings
if len(settingsJSON) > 0 {
err = json.Unmarshal([]byte(settingsJSON), &settings)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to unmarshal notifications settings.",
Detail: err.Error(),
})
return
}
}
httpapi.Write(r.Context(), rw, http.StatusOK, settings)
}
// @Summary Update notifications settings
// @ID update-notifications-settings
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Notifications
// @Param request body codersdk.NotificationsSettings true "Notifications settings request"
// @Success 200 {object} codersdk.NotificationsSettings
// @Success 304
// @Router /notifications/settings [put]
func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var settings codersdk.NotificationsSettings
if !httpapi.Read(ctx, rw, r, &settings) {
return
}
settingsJSON, err := json.Marshal(&settings)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to marshal notifications settings.",
Detail: err.Error(),
})
return
}
currentSettingsJSON, err := api.Database.GetNotificationsSettings(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch current notifications settings.",
Detail: err.Error(),
})
return
}
if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) {
// See: https://www.rfc-editor.org/rfc/rfc7232#section-4.1
httpapi.Write(ctx, rw, http.StatusNotModified, nil)
return
}
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.NotificationsSettings](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit()
aReq.New = database.NotificationsSettings{
ID: uuid.New(),
NotifierPaused: settings.NotifierPaused,
}
err = api.Database.UpsertNotificationsSettings(ctx, string(settingsJSON))
if err != nil {
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)
}
// 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, kind)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Failed to retrieve %q notifications templates.", kind),
Detail: err.Error(),
})
return
}
out := convertNotificationTemplates(templates)
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
// @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() {
// Skip inbox method as for now this is an implicit delivery target and should not appear
// anywhere in the Web UI.
if nm == database.NotificationMethodInbox {
continue
}
methods = append(methods, string(nm))
}
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.NotificationMethodsResponse{
AvailableNotificationMethods: methods,
DefaultNotificationMethod: api.DeploymentValues.Notifications.Method.Value(),
})
}
// @Summary Send a test notification
// @ID send-a-test-notification
// @Security CoderSessionToken
// @Tags Notifications
// @Success 200
// @Router /notifications/test [post]
func (api *API) postTestNotification(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
key = httpmw.APIKey(r)
)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
if _, err := api.NotificationsEnqueuer.EnqueueWithData(
//nolint:gocritic // We need to be notifier to send the notification.
dbauthz.AsNotifier(ctx),
key.UserID,
notifications.TemplateTestNotification,
map[string]string{},
map[string]any{
// NOTE(DanielleMaywood):
// When notifications are enqueued, they are checked to be
// unique within a single day. This means that if we attempt
// to send two test notifications to the same user on
// the same day, the enqueuer will prevent us from sending
// a second one. We are injecting a timestamp to make the
// notifications appear different enough to circumvent this
// deduplication logic.
"timestamp": api.Clock.Now(),
},
"send-test-notification",
); err != nil {
api.Logger.Error(ctx, "send notification", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to send test notification",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @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)
}
// @Summary Send a custom notification
// @ID send-a-custom-notification
// @Security CoderSessionToken
// @Tags Notifications
// @Accept json
// @Produce json
// @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"
// @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.CustomNotificationRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
// 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{
Message: "Invalid request body",
Detail: err.Error(),
})
return
}
// 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.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{
Message: "Forbidden",
Detail: "System users cannot send custom notifications.",
})
return
}
if _, err := api.NotificationsEnqueuer.EnqueueWithData(
//nolint:gocritic // We need to be notifier to send the notification.
dbauthz.AsNotifier(ctx),
user.ID,
notifications.TemplateCustomNotification,
map[string]string{
"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).
// 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.
"dedupe_bypass_ts": api.Clock.Now().UTC().Truncate(time.Minute),
},
user.ID.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{
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),
EnabledByDefault: tmpl.EnabledByDefault,
})
}
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
}