feat(notifications): ntfy/Gotify push channels with live delivery (#69)

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>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-10 01:39:09 +02:00
1 parent 60145f7b21
commit 9243d020cc
19 files changed
+1261 -6

No files matched your search

@@ -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"`
@@ -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
@@ -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)