mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
08d92fb10f
commit
07ac54aa05
14 files changed
+685
-45
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user