From 9243d020cc6dc35853b751b9081f656af0909629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:39:09 +0200 Subject: [PATCH] feat(notifications): ntfy/Gotify push channels with live delivery (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-user notification_channels (ntfy + Gotify): every insertUserNotification now fans out best-effort to enabled channels — deployment/backup/security/agent alerts reach the user's phone via the ntfy app with zero extra infrastructure. - POST/GET/DELETE /notifications/channels + POST .../:id/test which pushes a real probe so misconfiguration surfaces before alerts depend on it (502 DELIVERY_FAILED on endpoint errors). - ntfy: POST to endpoint (topic in path), Title/Tags headers, optional Bearer token. Gotify: POST {endpoint}/message?token=... - Delivery is best-effort by design — a dead relay never breaks the producing flow; one shared 5s-timeout client. - CLI `notifications channels list|add|test|remove`, MCP tools, OpenAPI paths + NotificationChannel schema. - Web settings "Push notifications" section and a Push channels card on the mobile notifications tab (add/test/remove). Verified live: channel CRUD, test-ping hit a recording endpoint with correct headers/body, and a real deployment fanned out "Deployment succeeded" through the channel end-to-end. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 6 +- .../internal/api/notification_channels.go | 225 ++++++++++++++++++ .../api/notification_channels_test.go | 80 +++++++ app/backend/internal/api/notifications.go | 1 + app/backend/internal/api/routes.go | 4 + .../internal/cli/commands/notifications.go | 102 +++++++- .../internal/database/sqlcdb/models.go | 11 + .../database/sqlcdb/notifications.sql.go | 129 ++++++++++ .../internal/database/sqlcdb/querier.go | 4 + app/backend/internal/mcpserver/server.go | 4 + .../20261116000000_notification_channels.sql | 16 ++ app/backend/sqlc/queries/notifications.sql | 20 ++ app/backend/sqlc/schema.sql | 11 + app/frontend/src/features/secondary/pages.tsx | 153 ++++++++++++ app/frontend/src/generated/api-types.ts | 172 +++++++++++++ app/frontend/src/lib/api-client.ts | 36 +++ app/mobile/src/api.ts | 35 +++ app/mobile/src/app/(tabs)/notifications.tsx | 132 +++++++++- docs/api/openapi.yaml | 126 ++++++++++ 19 files changed, 1261 insertions(+), 6 deletions(-) create mode 100644 app/backend/internal/api/notification_channels.go create mode 100644 app/backend/internal/api/notification_channels_test.go create mode 100644 app/backend/migrations_goose/20261116000000_notification_channels.sql diff --git a/ROADMAP2.md b/ROADMAP2.md index d737b7f..692d067 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -508,8 +508,10 @@ and wired refs, one click or one CLI call. - [x] **env var edit + domain management + replica scaling** (PR #61 — mobile v1.5: variables editor, domains screen, scale + sleep settings, notifications tab). **cron execution history** still open. - - [ ] **Push notifications** — zero-infra path = self-hosted - ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional. + - [x] **Push notifications** — per-user `notification_channels` + (ntfy + Gotify): every `insertUserNotification` fans out best-effort + to enabled channels; CRUD + live test-ping via web settings, CLI + `notifications channels`, and MCP. FCM/APNs remain optional. - [ ] **Offline resilience** — cached last-known state + unreachable banner. - [ ] **Status/offline resilience** — app + CLI degrade gracefully when diff --git a/app/backend/internal/api/notification_channels.go b/app/backend/internal/api/notification_channels.go new file mode 100644 index 0000000..658895c --- /dev/null +++ b/app/backend/internal/api/notification_channels.go @@ -0,0 +1,225 @@ +package api + +// Push notification channels — self-hosted ntfy and Gotify endpoints users +// attach to their account. insertUserNotification fans out best-effort. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "containr/internal/database" + "containr/internal/database/sqlcdb" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// jarvis: ceiling — one shared client; no retry/backoff, delivery is +// best-effort and failures are silent by design (notifications must never +// break the producing flow). +var pushHTTPClient = &http.Client{Timeout: 5 * time.Second} + +type notificationChannelDTO struct { + ID string `json:"id"` + Kind string `json:"kind"` + Endpoint string `json:"endpoint"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` +} + +func mapChannel(row sqlcdb.NotificationChannel) notificationChannelDTO { + return notificationChannelDTO{ + ID: row.ID.String(), + Kind: row.Kind, + Endpoint: row.Endpoint, + Enabled: row.Enabled, + CreatedAt: row.CreatedAt.Time.Format(time.RFC3339), + } +} + +func handleListNotificationChannels(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + uid, _ := uuid.Parse(userID) + rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list channels") + return + } + out := make([]notificationChannelDTO, 0, len(rows)) + for _, r := range rows { + out = append(out, mapChannel(r)) + } + c.JSON(http.StatusOK, gin.H{"channels": out}) +} + +func handleCreateNotificationChannel(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + var req struct { + Kind string `json:"kind" binding:"required,oneof=ntfy gotify"` + Endpoint string `json:"endpoint" binding:"required"` + Token string `json:"token"` + } + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "kind (ntfy|gotify) and endpoint are required") + return + } + endpoint := strings.TrimRight(strings.TrimSpace(req.Endpoint), "/") + u, err := url.Parse(endpoint) + if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + respondError(c, http.StatusBadRequest, "VALIDATION", "endpoint must be a valid http(s) URL") + return + } + db := c.MustGet("db").(*database.DB) + uid, _ := uuid.Parse(userID) + row, err := sqlcdb.New(db.DB).InsertNotificationChannel(c.Request.Context(), sqlcdb.InsertNotificationChannelParams{ + UserID: uid, + Kind: req.Kind, + Endpoint: endpoint, + Token: sql.NullString{String: req.Token, Valid: req.Token != ""}, + }) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create channel") + return + } + c.JSON(http.StatusCreated, gin.H{"channel": mapChannel(row)}) +} + +func handleDeleteNotificationChannel(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id") + return + } + db := c.MustGet("db").(*database.DB) + uid, _ := uuid.Parse(userID) + n, err := sqlcdb.New(db.DB).DeleteNotificationChannel(c.Request.Context(), sqlcdb.DeleteNotificationChannelParams{ + ID: id, UserID: uid, + }) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete channel") + return + } + if n == 0 { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found") + return + } + c.JSON(http.StatusOK, gin.H{"message": "Channel deleted"}) +} + +// handleTestNotificationChannel pushes a probe notification through the +// channel — surfaces misconfiguration before real alerts depend on it. +func handleTestNotificationChannel(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id") + return + } + db := c.MustGet("db").(*database.DB) + uid, _ := uuid.Parse(userID) + rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load channel") + return + } + var target *sqlcdb.NotificationChannel + for i := range rows { + if rows[i].ID == id { + target = &rows[i] + break + } + } + if target == nil { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found") + return + } + if err := deliverPush(c.Request.Context(), *target, "test", "Containr test notification", "Your push channel is configured correctly."); err != nil { + respondError(c, http.StatusBadGateway, "DELIVERY_FAILED", fmt.Sprintf("Push failed: %v", err)) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"}) +} + +// deliverPush sends one notification through a channel. ntfy expects the +// body at POST {endpoint} with Title/Priority headers; Gotify takes JSON at +// POST {endpoint}/message?token=... +func deliverPush(ctx context.Context, ch sqlcdb.NotificationChannel, kind, title, body string) error { + var req *http.Request + var err error + switch ch.Kind { + case "ntfy": + req, err = http.NewRequestWithContext(ctx, http.MethodPost, ch.Endpoint, strings.NewReader(body)) + if err == nil { + req.Header.Set("Title", title) + req.Header.Set("Tags", "containr,"+kind) + if ch.Token.Valid && ch.Token.String != "" { + req.Header.Set("Authorization", "Bearer "+ch.Token.String) + } + } + case "gotify": + payload, _ := json.Marshal(map[string]interface{}{ + "title": title, + "message": body, + "priority": 5, + }) + endpoint := ch.Endpoint + "/message" + if ch.Token.Valid && ch.Token.String != "" { + endpoint += "?token=" + url.QueryEscape(ch.Token.String) + } + req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err == nil { + req.Header.Set("Content-Type", "application/json") + } + default: + return fmt.Errorf("unsupported channel kind %q", ch.Kind) + } + if err != nil { + return err + } + res, err := pushHTTPClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= 400 { + return fmt.Errorf("push endpoint returned %d", res.StatusCode) + } + return nil +} + +// fanoutUserPush delivers a notification to every enabled channel the user +// configured. Errors are swallowed — the in-app row already landed. +func fanoutUserPush(db *database.DB, userID, kind, title, body string) { + uid, err := uuid.Parse(userID) + if err != nil { + return + } + rows, err := sqlcdb.New(db.DB).ListNotificationChannelsByUser(context.Background(), uid) + if err != nil || len(rows) == 0 { + return + } + for _, ch := range rows { + _ = deliverPush(context.Background(), ch, kind, title, body) + } +} diff --git a/app/backend/internal/api/notification_channels_test.go b/app/backend/internal/api/notification_channels_test.go new file mode 100644 index 0000000..c66c89d --- /dev/null +++ b/app/backend/internal/api/notification_channels_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "containr/internal/database/sqlcdb" +) + +func TestDeliverPushNtfy(t *testing.T) { + var gotAuth, gotTitle, gotTags, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotTitle = r.Header.Get("Title") + gotTags = r.Header.Get("Tags") + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ch := sqlcdb.NotificationChannel{ + Kind: "ntfy", + Endpoint: srv.URL + "/alerts", + Token: sql.NullString{String: "tk_abc", Valid: true}, + } + if err := deliverPush(context.Background(), ch, "deployment", "Deploy done", "svc is live"); err != nil { + t.Fatalf("deliverPush: %v", err) + } + if gotAuth != "Bearer tk_abc" { + t.Fatalf("auth header = %q", gotAuth) + } + if gotTitle != "Deploy done" || gotTags != "containr,deployment" || gotBody != "svc is live" { + t.Fatalf("title=%q tags=%q body=%q", gotTitle, gotTags, gotBody) + } +} + +func TestDeliverPushGotify(t *testing.T) { + var gotPath, gotToken string + var payload map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotToken = r.URL.Query().Get("token") + _ = json.NewDecoder(r.Body).Decode(&payload) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ch := sqlcdb.NotificationChannel{ + Kind: "gotify", + Endpoint: srv.URL, + Token: sql.NullString{String: "apptoken", Valid: true}, + } + if err := deliverPush(context.Background(), ch, "backup", "Backup done", "db backup ok"); err != nil { + t.Fatalf("deliverPush: %v", err) + } + if gotPath != "/message" || gotToken != "apptoken" { + t.Fatalf("path=%q token=%q", gotPath, gotToken) + } + if payload["title"] != "Backup done" || payload["message"] != "db backup ok" { + t.Fatalf("payload = %v", payload) + } +} + +func TestDeliverPushFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + ch := sqlcdb.NotificationChannel{Kind: "ntfy", Endpoint: srv.URL} + if err := deliverPush(context.Background(), ch, "k", "t", "b"); err == nil { + t.Fatal("expected error on 500") + } +} diff --git a/app/backend/internal/api/notifications.go b/app/backend/internal/api/notifications.go index 801b963..de75154 100644 --- a/app/backend/internal/api/notifications.go +++ b/app/backend/internal/api/notifications.go @@ -41,6 +41,7 @@ func insertUserNotification(db *database.DB, userID, kind, title, body, resource ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""}, CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, }) + go fanoutUserPush(db, userID, kind, title, body) } func mapNotification(row sqlcdb.Notification) NotificationItem { diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index c2218c8..dab0f46 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -364,6 +364,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.GET("/notifications", handleListNotifications) authed.POST("/notifications/:id/read", handleMarkNotificationRead) authed.POST("/notifications/read-all", handleMarkAllNotificationsRead) + authed.GET("/notifications/channels", handleListNotificationChannels) + authed.POST("/notifications/channels", handleCreateNotificationChannel) + authed.POST("/notifications/channels/:id/test", handleTestNotificationChannel) + authed.DELETE("/notifications/channels/:id", handleDeleteNotificationChannel) // WebSocket endpoint authed.GET("/ws", handleWebSocket) diff --git a/app/backend/internal/cli/commands/notifications.go b/app/backend/internal/cli/commands/notifications.go index 024e89b..cfe375f 100644 --- a/app/backend/internal/cli/commands/notifications.go +++ b/app/backend/internal/cli/commands/notifications.go @@ -1,6 +1,8 @@ package commands import ( + "fmt" + "github.com/spf13/cobra" ) @@ -60,6 +62,104 @@ var notifReadCmd = &cobra.Command{ }, } +var notifChannelsCmd = &cobra.Command{ + Use: "channels", + Short: "Manage push notification channels (ntfy/Gotify)", +} + +var notifChannelsListCmd = &cobra.Command{ + Use: "list", + Short: "List push channels", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("GET", "/notifications/channels", nil) + if err != nil { + return err + } + items, err := unwrapList(data, "channels") + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, n := range items { + rows = append(rows, []string{ + str(n, "id"), str(n, "kind"), str(n, "endpoint"), str(n, "enabled"), relTime(n, "created_at"), + }) + } + printRows(data, []string{"ID", "KIND", "ENDPOINT", "ENABLED", "AGE"}, rows) + return nil + }, +} + +var notifChannelsAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a push channel (endpoint includes topic for ntfy)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + token, _ := cmd.Flags().GetString("token") + data, err := c.Do("POST", "/notifications/channels", map[string]interface{}{ + "kind": args[0], + "endpoint": args[1], + "token": token, + }) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var notifChannelsTestCmd = &cobra.Command{ + Use: "test ", + Short: "Send a test notification through a channel", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("POST", "/notifications/channels/"+args[0]+"/test", map[string]interface{}{}) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var notifChannelsRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a push channel", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + yes, _ := cmd.Flags().GetBool("yes") + if !yes && !Confirm(fmt.Sprintf("Remove channel %s?", args[0])) { + return nil + } + c, err := client() + if err != nil { + return err + } + data, err := c.Do("DELETE", "/notifications/channels/"+args[0], nil) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + func init() { - NotificationsCmd.AddCommand(notifListCmd, notifReadCmd) + notifChannelsAddCmd.Flags().String("token", "", "Auth token (ntfy access token or Gotify app token)") + notifChannelsRemoveCmd.Flags().Bool("yes", false, "Skip confirmation") + notifChannelsCmd.AddCommand(notifChannelsListCmd, notifChannelsAddCmd, notifChannelsTestCmd, notifChannelsRemoveCmd) + NotificationsCmd.AddCommand(notifListCmd, notifReadCmd, notifChannelsCmd) } diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index a80f64f..c91e277 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -193,6 +193,17 @@ type Notification struct { CreatedAt sql.NullTime `json:"created_at"` } +type NotificationChannel struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Kind string `json:"kind"` + Endpoint string `json:"endpoint"` + Token sql.NullString `json:"token"` + Enabled bool `json:"enabled"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + type OutboundWebhook struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` diff --git a/app/backend/internal/database/sqlcdb/notifications.sql.go b/app/backend/internal/database/sqlcdb/notifications.sql.go index 8fcc9c0..5f6e23a 100644 --- a/app/backend/internal/database/sqlcdb/notifications.sql.go +++ b/app/backend/internal/database/sqlcdb/notifications.sql.go @@ -53,6 +53,135 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification return err } +const deleteNotificationChannel = `-- name: DeleteNotificationChannel :execrows +DELETE FROM notification_channels WHERE id = $1 AND user_id = $2 +` + +type DeleteNotificationChannelParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteNotificationChannel, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const insertNotificationChannel = `-- name: InsertNotificationChannel :one +INSERT INTO notification_channels (user_id, kind, endpoint, token) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at +` + +type InsertNotificationChannelParams struct { + UserID uuid.UUID `json:"user_id"` + Kind string `json:"kind"` + Endpoint string `json:"endpoint"` + Token sql.NullString `json:"token"` +} + +func (q *Queries) InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) { + row := q.db.QueryRowContext(ctx, insertNotificationChannel, + arg.UserID, + arg.Kind, + arg.Endpoint, + arg.Token, + ) + var i NotificationChannel + err := row.Scan( + &i.ID, + &i.UserID, + &i.Kind, + &i.Endpoint, + &i.Token, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listAllNotificationChannelsByUser = `-- name: ListAllNotificationChannelsByUser :many +SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at +FROM notification_channels +WHERE user_id = $1 +ORDER BY created_at +` + +func (q *Queries) ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) { + rows, err := q.db.QueryContext(ctx, listAllNotificationChannelsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []NotificationChannel{} + for rows.Next() { + var i NotificationChannel + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Kind, + &i.Endpoint, + &i.Token, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listNotificationChannelsByUser = `-- name: ListNotificationChannelsByUser :many +SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at +FROM notification_channels +WHERE user_id = $1 AND enabled +ORDER BY created_at +` + +func (q *Queries) ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) { + rows, err := q.db.QueryContext(ctx, listNotificationChannelsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []NotificationChannel{} + for rows.Next() { + var i NotificationChannel + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Kind, + &i.Endpoint, + &i.Token, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listNotificationsByUser = `-- name: ListNotificationsByUser :many SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at FROM notifications diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 82844a7..8fafab2 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -44,6 +44,7 @@ type Querier interface { DeleteBanner(ctx context.Context, id uuid.UUID) error DeleteDatabaseServiceByID(ctx context.Context, id string) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error + DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error @@ -75,12 +76,14 @@ type Querier interface { GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error + InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error ListActiveBanners(ctx context.Context) ([]Banner, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgents(ctx context.Context) ([]NodeAgent, error) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) + ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) ListBanners(ctx context.Context) ([]Banner, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) @@ -90,6 +93,7 @@ type Querier interface { ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) + ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index ba0f46d..30035ad 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -145,6 +145,10 @@ func init() { // Notifications register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false}) register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false}) + register(spec{"containr_notification_channels_list", "List push notification channels (ntfy/Gotify)", "GET", "/notifications/channels", nil, nil, false}) + register(spec{"containr_notification_channels_create", "Add a push notification channel. Args: kind (ntfy|gotify), endpoint (full URL incl. ntfy topic or Gotify base), token (ntfy access token or Gotify app token).", "POST", "/notifications/channels", nil, []string{"kind", "endpoint", "token"}, false}) + register(spec{"containr_notification_channels_test", "Send a test notification through a push channel. Args: id.", "POST", "/notifications/channels/{id}/test", []string{"id"}, nil, false}) + register(spec{"containr_notification_channels_delete", "Delete a push notification channel. Args: id. Requires confirm=true.", "DELETE", "/notifications/channels/{id}", []string{"id"}, nil, true}) register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false}) register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false}) diff --git a/app/backend/migrations_goose/20261116000000_notification_channels.sql b/app/backend/migrations_goose/20261116000000_notification_channels.sql new file mode 100644 index 0000000..3298dd4 --- /dev/null +++ b/app/backend/migrations_goose/20261116000000_notification_channels.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS public.notification_channels ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + kind varchar(20) NOT NULL CHECK (kind IN ('ntfy', 'gotify')), + endpoint text NOT NULL, + token varchar(255), + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_notification_channels_user ON public.notification_channels (user_id); + +-- +goose Down +DROP TABLE IF EXISTS public.notification_channels; diff --git a/app/backend/sqlc/queries/notifications.sql b/app/backend/sqlc/queries/notifications.sql index d9f8ef8..11ab207 100644 --- a/app/backend/sqlc/queries/notifications.sql +++ b/app/backend/sqlc/queries/notifications.sql @@ -17,3 +17,23 @@ UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at -- name: MarkAllNotificationsReadByUser :exec UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL; + +-- name: ListNotificationChannelsByUser :many +SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at +FROM notification_channels +WHERE user_id = $1 AND enabled +ORDER BY created_at; + +-- name: ListAllNotificationChannelsByUser :many +SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at +FROM notification_channels +WHERE user_id = $1 +ORDER BY created_at; + +-- name: InsertNotificationChannel :one +INSERT INTO notification_channels (user_id, kind, endpoint, token) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at; + +-- name: DeleteNotificationChannel :execrows +DELETE FROM notification_channels WHERE id = $1 AND user_id = $2; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index b8f91bf..10f0cc2 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -335,3 +335,14 @@ CREATE TABLE user_invites ( used_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); + +CREATE TABLE notification_channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind VARCHAR(20) NOT NULL CHECK (kind IN ('ntfy', 'gotify')), + endpoint TEXT NOT NULL, + token VARCHAR(255), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 95a8fc7..8a68467 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -75,6 +75,11 @@ import { type CreateDatabaseInput, type DatabaseEntity, type FailoverPolicy, + listNotificationChannels, + createNotificationChannel, + deleteNotificationChannel, + testNotificationChannel, + type NotificationChannel, } from '@/lib/api-client'; import { demoBuilds, @@ -139,6 +144,8 @@ import { Cloud, ExternalLink, Upload, + Bell, + Send, } from 'lucide-react'; function SecondaryPageHeader({ title, description }: { title: string; description: string }) { @@ -1908,6 +1915,151 @@ function BackupTargetsSection() { ); } +function NotificationChannelsSection() { + const queryClient = useQueryClient(); + const channelsQuery = useQuery({ + queryKey: ['notification-channels'], + queryFn: listNotificationChannels, + }); + const [kind, setKind] = useState<'ntfy' | 'gotify'>('ntfy'); + const [endpoint, setEndpoint] = useState(''); + const [token, setToken] = useState(''); + const [testResult, setTestResult] = useState>({}); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['notification-channels'] }); + const createMutation = useMutation({ + mutationFn: () => + createNotificationChannel({ + kind, + endpoint: endpoint.trim(), + token: token.trim() || undefined, + }), + onSuccess: () => { + setEndpoint(''); + setToken(''); + invalidate(); + }, + }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteNotificationChannel(id), + onSuccess: invalidate, + }); + const testMutation = useMutation({ + mutationFn: (id: string) => testNotificationChannel(id), + onSuccess: (_d, id) => setTestResult((prev) => ({ ...prev, [id]: 'ok' })), + onError: (err, id) => + setTestResult((prev) => ({ + ...prev, + [id]: err instanceof Error ? err.message : 'delivery failed', + })), + }); + + const channels = channelsQuery.data ?? []; + + return ( +
+
+
+ +
+
+

Push notifications

+

Relay notifications to a self-hosted ntfy or Gotify server

+
+
+ +
+
+ + setEndpoint(e.target.value)} + placeholder={kind === 'ntfy' ? 'https://ntfy.example.com/my-alerts' : 'https://gotify.example.com'} + className="h-9 flex-1 min-w-[220px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" + /> + setToken(e.target.value)} + placeholder={kind === 'ntfy' ? 'Access token (optional)' : 'App token'} + className="h-9 w-48 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" + /> + +
+ + {createMutation.isError ? ( +

+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add channel'} +

+ ) : null} + + {channelsQuery.isLoading ? ( +
+ +
+ ) : channels.length === 0 ? ( +

+ No push channels. Notifications stay in-app until you add one. +

+ ) : ( +
+ {channels.map((ch: NotificationChannel) => ( +
+
+ + {ch.kind} + + {ch.endpoint} + {testResult[ch.id] === 'ok' ? ( + delivered + ) : testResult[ch.id] ? ( + {testResult[ch.id]} + ) : null} +
+
+ + +
+
+ ))} +
+ )} +
+
+ ); +} + export function SettingsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -2081,6 +2233,7 @@ export function SettingsPage() { {/* Personal Access Tokens */} + diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 6a5d403..50e3d0f 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -3698,6 +3698,167 @@ export interface paths { patch?: never; trace?: never; }; + "/notifications/channels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List push notification channels (ntfy/Gotify) */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Channel list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + channels?: components["schemas"]["NotificationChannel"][]; + }; + }; + }; + }; + }; + put?: never; + /** + * Add a push notification channel + * @description ntfy endpoint should include the topic (e.g. https://ntfy.example.com/my-alerts); Gotify endpoint is the server base URL with the app token in `token`. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + kind: "ntfy" | "gotify"; + /** Format: uri */ + endpoint: string; + /** @description ntfy access token (Bearer) or Gotify app token */ + token?: string; + }; + }; + }; + responses: { + /** @description Channel created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + channel?: components["schemas"]["NotificationChannel"]; + }; + }; + }; + 400: components["schemas"]["Error"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications/channels/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a push notification channel */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + 404: components["schemas"]["Error"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications/channels/{id}/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send a test notification through a push channel */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Test delivered */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Push delivery failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/webhooks": { parameters: { query?: never; @@ -11824,6 +11985,17 @@ export interface components { notifications?: components["schemas"]["Notification"][]; unread?: number; }; + NotificationChannel: { + /** Format: uuid */ + id?: string; + /** @enum {string} */ + kind?: "ntfy" | "gotify"; + /** Format: uri */ + endpoint?: string; + enabled?: boolean; + /** Format: date-time */ + created_at?: string; + }; OutboundWebhook: { /** Format: uuid */ id?: string; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 9eb5cfe..c81d263 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -2596,6 +2596,42 @@ export async function markAllNotificationsRead(): Promise { await requestJson('/notifications/read-all', { method: 'POST' }); } +export type NotificationChannel = { + id: string; + kind: 'ntfy' | 'gotify'; + endpoint: string; + enabled: boolean; + created_at: string; +}; + +export async function listNotificationChannels(): Promise { + const payload = await requestJson<{ channels?: NotificationChannel[] }>('/notifications/channels'); + return payload.channels ?? []; +} + +export async function createNotificationChannel(input: { + kind: 'ntfy' | 'gotify'; + endpoint: string; + token?: string; +}): Promise { + const payload = await requestJson<{ channel: NotificationChannel }>('/notifications/channels', { + method: 'POST', + body: JSON.stringify(input), + }); + return payload.channel; +} + +export async function deleteNotificationChannel(id: string): Promise { + await requestJson(`/notifications/channels/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export async function testNotificationChannel(id: string): Promise { + await requestJson(`/notifications/channels/${encodeURIComponent(id)}/test`, { + method: 'POST', + body: JSON.stringify({}), + }); +} + export type PreviewEnvironmentEntity = components['schemas']['PreviewEnvironment']; export async function listPreviewEnvironments(projectId: string): Promise { diff --git a/app/mobile/src/api.ts b/app/mobile/src/api.ts index 809d3ec..46ea2c9 100644 --- a/app/mobile/src/api.ts +++ b/app/mobile/src/api.ts @@ -94,6 +94,14 @@ export interface AppNotification { created_at?: string; } +export interface NotificationChannel { + id: string; + kind: 'ntfy' | 'gotify'; + endpoint: string; + enabled: boolean; + created_at?: string; +} + // Masked value the API returns for secret variables; echoing it back on // update preserves the stored ciphertext. export const MASKED_SECRET = '********'; @@ -238,6 +246,33 @@ export class Api { return this.req('POST', '/notifications/read-all'); } + notificationChannels() { + return this.req<{ channels: NotificationChannel[] }>( + 'GET', + '/notifications/channels', + ).then((r) => r.channels ?? []); + } + + addNotificationChannel(input: { + kind: 'ntfy' | 'gotify'; + endpoint: string; + token?: string; + }) { + return this.req<{ channel: NotificationChannel }>( + 'POST', + '/notifications/channels', + input, + ); + } + + deleteNotificationChannel(id: string) { + return this.req('DELETE', `/notifications/channels/${id}`); + } + + testNotificationChannel(id: string) { + return this.req('POST', `/notifications/channels/${id}/test`, {}); + } + serviceDeployments(id: string) { return this.req<{ deployments: Deployment[] }>( 'GET', diff --git a/app/mobile/src/app/(tabs)/notifications.tsx b/app/mobile/src/app/(tabs)/notifications.tsx index 11722f1..4ec9984 100644 --- a/app/mobile/src/app/(tabs)/notifications.tsx +++ b/app/mobile/src/app/(tabs)/notifications.tsx @@ -1,10 +1,10 @@ -import React from 'react'; -import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native'; +import React, { useState } from 'react'; +import { FlatList, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useApi } from '../../session'; import { colors } from '../../theme'; import { relTime } from '../../time'; -import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components'; +import { ActionButton, Card, Empty, ErrorBox, Loading, SectionTitle } from '../../components'; const KIND_COLOR: Record = { deployment_failed: colors.err, @@ -59,6 +59,7 @@ export default function Notifications() { ) : null } ListEmptyComponent={} + ListFooterComponent={} data={items} keyExtractor={(n) => n.id} renderItem={({ item }) => { @@ -96,6 +97,122 @@ export default function Notifications() { ); } +function PushChannelsCard() { + const api = useApi(); + const qc = useQueryClient(); + const channels = useQuery({ + queryKey: ['notification-channels'], + queryFn: () => api.notificationChannels(), + }); + const [kind, setKind] = useState<'ntfy' | 'gotify'>('ntfy'); + const [endpoint, setEndpoint] = useState(''); + const [token, setToken] = useState(''); + const [adding, setAdding] = useState(false); + + const invalidate = () => qc.invalidateQueries({ queryKey: ['notification-channels'] }); + const add = useMutation({ + mutationFn: () => + api.addNotificationChannel({ + kind, + endpoint: endpoint.trim(), + token: token.trim() || undefined, + }), + onSuccess: () => { + setEndpoint(''); + setToken(''); + setAdding(false); + invalidate(); + }, + }); + const remove = useMutation({ + mutationFn: (id: string) => api.deleteNotificationChannel(id), + onSuccess: invalidate, + }); + const test = useMutation({ + mutationFn: (id: string) => api.testNotificationChannel(id), + }); + + const list = channels.data ?? []; + + return ( + + Push channels + + + Relay alerts to a self-hosted ntfy or Gotify server — your phone gets pushes via the ntfy app, no account required here. + + {list.map((ch) => ( + + + {ch.kind.toUpperCase()} + + {ch.endpoint} + + + test.mutate(ch.id)} + > + {test.isPending ? '…' : 'Test'} + + remove.mutate(ch.id)} + > + Remove + + + ))} + {adding ? ( + + + {(['ntfy', 'gotify'] as const).map((k) => ( + setKind(k)} + > + {k} + + ))} + + + + {add.error ? : null} + add.mutate()} + /> + + ) : ( + setAdding(true)}> + + Add channel + + )} + + + ); +} + const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg }, row: { flexDirection: 'row', alignItems: 'flex-start' }, @@ -104,4 +221,13 @@ const styles = StyleSheet.create({ body: { color: colors.textDim, fontSize: 13, marginTop: 4, lineHeight: 18 }, meta: { color: colors.textDim, fontSize: 11, marginTop: 6 }, markRead: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 8 }, + hint: { color: colors.textDim, fontSize: 12, lineHeight: 17, marginBottom: 8 }, + channelRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }, + channelKind: { color: colors.text, fontSize: 12, fontWeight: '700' }, + channelEndpoint: { color: colors.textDim, fontSize: 12, marginTop: 2 }, + channelAction: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 12 }, + kindRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, + kindChip: { color: colors.textDim, fontSize: 13, paddingVertical: 4, paddingHorizontal: 12, borderRadius: 12, borderWidth: 1, borderColor: colors.border }, + kindChipActive: { color: colors.accent, borderColor: colors.accent }, + input: { color: colors.text, fontSize: 14, borderWidth: 1, borderColor: colors.border, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, marginBottom: 8 }, }); diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d1642c9..af0bdfc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2474,6 +2474,114 @@ paths: schema: $ref: '#/components/schemas/MessageResponse' + /notifications/channels: + get: + tags: + - Notifications + summary: List push notification channels (ntfy/Gotify) + security: + - bearerAuth: [] + responses: + '200': + description: Channel list + content: + application/json: + schema: + type: object + properties: + channels: + type: array + items: + $ref: '#/components/schemas/NotificationChannel' + post: + tags: + - Notifications + summary: Add a push notification channel + description: ntfy endpoint should include the topic (e.g. https://ntfy.example.com/my-alerts); Gotify endpoint is the server base URL with the app token in `token`. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [kind, endpoint] + properties: + kind: + type: string + enum: [ntfy, gotify] + endpoint: + type: string + format: uri + token: + type: string + description: ntfy access token (Bearer) or Gotify app token + responses: + '201': + description: Channel created + content: + application/json: + schema: + type: object + properties: + channel: + $ref: '#/components/schemas/NotificationChannel' + '400': + $ref: '#/components/schemas/Error' + + /notifications/channels/{id}: + delete: + tags: + - Notifications + summary: Delete a push notification channel + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '404': + $ref: '#/components/schemas/Error' + + /notifications/channels/{id}/test: + post: + tags: + - Notifications + summary: Send a test notification through a push channel + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Test delivered + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '502': + description: Push delivery failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # Outbound Webhooks /webhooks: get: @@ -8158,6 +8266,24 @@ components: unread: type: integer + NotificationChannel: + type: object + properties: + id: + type: string + format: uuid + kind: + type: string + enum: [ntfy, gotify] + endpoint: + type: string + format: uri + enabled: + type: boolean + created_at: + type: string + format: date-time + OutboundWebhook: type: object properties: