Files
coder/codersdk/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

334 lines
9.9 KiB
Go

package codersdk
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
)
type NotificationsSettings struct {
NotifierPaused bool `json:"notifier_paused"`
}
type NotificationTemplate struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
TitleTemplate string `json:"title_template"`
BodyTemplate string `json:"body_template"`
Actions string `json:"actions" format:""`
Group string `json:"group"`
Method string `json:"method"`
Kind string `json:"kind"`
EnabledByDefault bool `json:"enabled_by_default"`
}
type NotificationMethodsResponse struct {
AvailableNotificationMethods []string `json:"available"`
DefaultNotificationMethod string `json:"default"`
}
type NotificationPreference struct {
NotificationTemplateID uuid.UUID `json:"id" format:"uuid"`
Disabled bool `json:"disabled"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
// GetNotificationsSettings retrieves the notifications settings, which currently just describes whether all
// notifications are paused from sending.
func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSettings, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/settings", nil)
if err != nil {
return NotificationsSettings{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return NotificationsSettings{}, ReadBodyAsError(res)
}
var settings NotificationsSettings
return settings, json.NewDecoder(res.Body).Decode(&settings)
}
// PutNotificationsSettings modifies the notifications settings, which currently just controls whether all
// notifications are paused from sending.
func (c *Client) PutNotificationsSettings(ctx context.Context, settings NotificationsSettings) error {
res, err := c.Request(ctx, http.MethodPut, "/api/v2/notifications/settings", settings)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotModified {
return nil
}
if res.StatusCode != http.StatusOK {
return ReadBodyAsError(res)
}
return nil
}
// UpdateNotificationTemplateMethod modifies a notification template to use a specific notification method, overriding
// the method set in the deployment configuration.
func (c *Client) UpdateNotificationTemplateMethod(ctx context.Context, notificationTemplateID uuid.UUID, method string) error {
res, err := c.Request(ctx, http.MethodPut,
fmt.Sprintf("/api/v2/notifications/templates/%s/method", notificationTemplateID),
UpdateNotificationTemplateMethod{Method: method},
)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotModified {
return nil
}
if res.StatusCode != http.StatusOK {
return ReadBodyAsError(res)
}
return nil
}
// GetSystemNotificationTemplates retrieves all notification templates pertaining to internal system events.
func (c *Client) GetSystemNotificationTemplates(ctx context.Context) ([]NotificationTemplate, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/templates/system", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var templates []NotificationTemplate
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, xerrors.Errorf("read response body: %w", err)
}
if err := json.Unmarshal(body, &templates); err != nil {
return nil, xerrors.Errorf("unmarshal response body: %w", err)
}
return templates, nil
}
// GetUserNotificationPreferences retrieves notification preferences for a given user.
func (c *Client) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var prefs []NotificationPreference
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, xerrors.Errorf("read response body: %w", err)
}
if err := json.Unmarshal(body, &prefs); err != nil {
return nil, xerrors.Errorf("unmarshal response body: %w", err)
}
return prefs, nil
}
// UpdateUserNotificationPreferences updates notification preferences for a given user.
func (c *Client) UpdateUserNotificationPreferences(ctx context.Context, userID uuid.UUID, req UpdateUserNotificationPreferences) ([]NotificationPreference, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var prefs []NotificationPreference
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, xerrors.Errorf("read response body: %w", err)
}
if err := json.Unmarshal(body, &prefs); err != nil {
return nil, xerrors.Errorf("unmarshal response body: %w", err)
}
return prefs, nil
}
// GetNotificationDispatchMethods the available and default notification dispatch methods.
func (c *Client) GetNotificationDispatchMethods(ctx context.Context) (NotificationMethodsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/dispatch-methods", nil)
if err != nil {
return NotificationMethodsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return NotificationMethodsResponse{}, ReadBodyAsError(res)
}
var resp NotificationMethodsResponse
body, err := io.ReadAll(res.Body)
if err != nil {
return NotificationMethodsResponse{}, xerrors.Errorf("read response body: %w", err)
}
if err := json.Unmarshal(body, &resp); err != nil {
return NotificationMethodsResponse{}, xerrors.Errorf("unmarshal response body: %w", err)
}
return resp, nil
}
func (c *Client) PostTestNotification(ctx context.Context) error {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/notifications/test", nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
type UpdateNotificationTemplateMethod struct {
Method string `json:"method,omitempty" example:"webhook"`
}
type UpdateUserNotificationPreferences struct {
TemplateDisabledMap map[string]bool `json:"template_disabled_map"`
}
type WebpushMessageAction struct {
Label string `json:"label"`
URL string `json:"url"`
}
type WebpushMessage struct {
Icon string `json:"icon"`
Title string `json:"title"`
Body string `json:"body"`
Actions []WebpushMessageAction `json:"actions"`
}
type WebpushSubscription struct {
Endpoint string `json:"endpoint"`
AuthKey string `json:"auth_key"`
P256DHKey string `json:"p256dh_key"`
}
type DeleteWebpushSubscription struct {
Endpoint string `json:"endpoint"`
}
// PostWebpushSubscription creates a push notification subscription for a given user.
func (c *Client) PostWebpushSubscription(ctx context.Context, user string, req WebpushSubscription) error {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/webpush/subscription", user), req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// DeleteWebpushSubscription deletes a push notification subscription for a given user.
// Think of this as an unsubscribe, but for a specific push notification subscription.
func (c *Client) DeleteWebpushSubscription(ctx context.Context, user string, req DeleteWebpushSubscription) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/webpush/subscription", user), req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
func (c *Client) PostTestWebpushMessage(ctx context.Context) error {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/webpush/test", Me), WebpushMessage{
Title: "It's working!",
Body: "You've subscribed to push notifications.",
})
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
type CustomNotificationContent struct {
Title string `json:"title"`
Message string `json:"message"`
}
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
}
const (
maxCustomNotificationTitleLen = 120
maxCustomNotificationMessageLen = 2000
)
func (c CustomNotificationRequest) Validate() error {
if c.Content == nil {
return xerrors.Errorf("content is required")
}
return c.Content.Validate()
}
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.Message) > maxCustomNotificationMessageLen {
return xerrors.Errorf("'content.message' must be less than %d characters", maxCustomNotificationMessageLen)
}
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
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}