mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
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)
|
||||
|
||||
Reference in new issue
Block a user