Files
Containr/app/backend/internal/api/notifications.go
Tomáš DvořákandDevin 9243d020cc 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>
2026-10-10 01:39:09 +02:00

164 lines
4.9 KiB
Go

package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type NotificationItem struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
ReadAt *time.Time `json:"read_at"`
CreatedAt time.Time `json:"created_at"`
}
// insertUserNotification records a persistent notification for a user.
// Best-effort: notification delivery must never break the caller's flow.
func insertUserNotification(db *database.DB, userID, kind, title, body, resourceType, resourceID string) {
userUUID, err := uuid.Parse(userID)
if err != nil || db == nil {
return
}
_ = sqlcdb.New(db.DB).CreateNotification(context.Background(), sqlcdb.CreateNotificationParams{
ID: uuid.New(),
UserID: userUUID,
Kind: kind,
Title: title,
Body: sql.NullString{String: body, Valid: body != ""},
ResourceType: sql.NullString{String: resourceType, Valid: resourceType != ""},
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 {
var readAt *time.Time
if row.ReadAt.Valid {
t := row.ReadAt.Time
readAt = &t
}
return NotificationItem{
ID: row.ID.String(),
Kind: row.Kind,
Title: row.Title,
Body: databaseNullString(row.Body),
ResourceType: databaseNullString(row.ResourceType),
ResourceID: databaseNullString(row.ResourceID),
ReadAt: readAt,
CreatedAt: databaseNullTime(row.CreatedAt),
}
}
func handleListNotifications(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
q := sqlcdb.New(db.(*database.DB).DB)
limit := int64(20)
if raw := c.Query("limit"); raw != "" {
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
userUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
return
}
rows, err := q.ListNotificationsByUser(c.Request.Context(), sqlcdb.ListNotificationsByUserParams{
UserID: userUUID,
Limit: int32(limit),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"})
return
}
unread, err := q.CountUnreadNotificationsByUser(c.Request.Context(), userUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count notifications"})
return
}
items := make([]NotificationItem, 0, len(rows))
for _, row := range rows {
items = append(items, mapNotification(row))
}
c.JSON(http.StatusOK, gin.H{"notifications": items, "unread": unread})
}
func handleMarkNotificationRead(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"})
return
}
userUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
return
}
q := sqlcdb.New(db.(*database.DB).DB)
if err := q.MarkNotificationReadByIDAndUser(c.Request.Context(), sqlcdb.MarkNotificationReadByIDAndUserParams{
ID: id,
UserID: userUUID,
ReadAt: sql.NullTime{Time: time.Now(), Valid: true},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Notification marked read"})
}
func handleMarkAllNotificationsRead(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
return
}
q := sqlcdb.New(db.(*database.DB).DB)
if err := q.MarkAllNotificationsReadByUser(c.Request.Context(), sqlcdb.MarkAllNotificationsReadByUserParams{
UserID: userUUID,
ReadAt: sql.NullTime{Time: time.Now(), Valid: true},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notifications"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "All notifications marked read"})
}