Files
Containr/app/backend/internal/api/notifications.go
Tomas Dvorak 07ac54aa05 feat(notifications): persistent notification center
- notifications table (goose migration) + sqlc queries; list with unread
  count, mark-one, mark-all endpoints under /notifications.
- Deployment success/failure now writes a notification row for the owner
  (insertUserNotification — best-effort, never breaks the deploy flow).
- Shell bell reads the real persisted feed: unread badge, click-to-read,
  mark-all, 30s polling. Replaces the transient builds+audit derivation.

Verified e2e: migration applies clean, list/read/read-all round-trips.

Authored By: TDvorak <[email protected]>
2026-09-17 17:52:37 +02:00

163 lines
4.8 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},
})
}
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"})
}