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]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 17:52:37 +02:00
1 parent 08d92fb10f
commit 07ac54aa05
14 files changed
+685 -45

No files matched your search

+8
View File
@@ -4,6 +4,7 @@ import (
"containr/internal/database"
"containr/internal/deployment"
"context"
"fmt"
"net/http"
"strings"
"time"
@@ -368,12 +369,19 @@ func runDeploymentAndSync(
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
insertUserNotification(db, userID, "deployment", "Deployment succeeded",
fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String())
return
case "failed":
_, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
body := fmt.Sprintf("Service %s failed to deploy.", service.Name)
if current.Error != "" {
body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error)
}
insertUserNotification(db, userID, "deployment", "Deployment failed", body, "service", service.ID.String())
return
}
}
+162
View File
@@ -0,0 +1,162 @@
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"})
}
+5
View File
@@ -252,6 +252,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
// Notification routes
protected.GET("/notifications", handleListNotifications)
protected.POST("/notifications/:id/read", handleMarkNotificationRead)
protected.POST("/notifications/read-all", handleMarkAllNotificationsRead)
// Node Agent routes
agentHandler.SetupRoutes(protected)
@@ -137,6 +137,18 @@ type NodeAgent struct {
Metadata pqtype.NullRawMessage `json:"metadata"`
}
type Notification struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body sql.NullString `json:"body"`
ResourceType sql.NullString `json:"resource_type"`
ResourceID sql.NullString `json:"resource_id"`
ReadAt sql.NullTime `json:"read_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
type Project struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
@@ -0,0 +1,129 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: notifications.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countUnreadNotificationsByUser = `-- name: CountUnreadNotificationsByUser :one
SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL
`
func (q *Queries) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countUnreadNotificationsByUser, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const createNotification = `-- name: CreateNotification :exec
INSERT INTO notifications (id, user_id, kind, title, body, resource_type, resource_id, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
type CreateNotificationParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body sql.NullString `json:"body"`
ResourceType sql.NullString `json:"resource_type"`
ResourceID sql.NullString `json:"resource_id"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotificationParams) error {
_, err := q.db.ExecContext(ctx, createNotification,
arg.ID,
arg.UserID,
arg.Kind,
arg.Title,
arg.Body,
arg.ResourceType,
arg.ResourceID,
arg.CreatedAt,
)
return err
}
const listNotificationsByUser = `-- name: ListNotificationsByUser :many
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListNotificationsByUserParams struct {
UserID uuid.UUID `json:"user_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) {
rows, err := q.db.QueryContext(ctx, listNotificationsByUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Notification{}
for rows.Next() {
var i Notification
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Title,
&i.Body,
&i.ResourceType,
&i.ResourceID,
&i.ReadAt,
&i.CreatedAt,
); 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 markAllNotificationsReadByUser = `-- name: MarkAllNotificationsReadByUser :exec
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL
`
type MarkAllNotificationsReadByUserParams struct {
UserID uuid.UUID `json:"user_id"`
ReadAt sql.NullTime `json:"read_at"`
}
func (q *Queries) MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error {
_, err := q.db.ExecContext(ctx, markAllNotificationsReadByUser, arg.UserID, arg.ReadAt)
return err
}
const markNotificationReadByIDAndUser = `-- name: MarkNotificationReadByIDAndUser :exec
UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at IS NULL
`
type MarkNotificationReadByIDAndUserParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
ReadAt sql.NullTime `json:"read_at"`
}
func (q *Queries) MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, markNotificationReadByIDAndUser, arg.ID, arg.UserID, arg.ReadAt)
return err
}
@@ -15,12 +15,14 @@ type Querier interface {
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
@@ -50,10 +52,13 @@ type Querier interface {
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error