mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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>
164 lines
4.9 KiB
Go
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"})
|
|
}
|