mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +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
+7
-2
@@ -315,8 +315,13 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo.
|
|||||||
- [ ] Note: APwhy's own auth/RBAC/deploy/database endpoints are *not*
|
- [ ] Note: APwhy's own auth/RBAC/deploy/database endpoints are *not*
|
||||||
ported — Better Auth owns identity, Containr owns deployments and
|
ported — Better Auth owns identity, Containr owns deployments and
|
||||||
databases natively.
|
databases natively.
|
||||||
- [ ] **Notifications depth**: notification center persistence (read/unread),
|
- [x] **Notifications depth**: `notifications` table + `GET /notifications`,
|
||||||
not just a transient bell feed.
|
`POST /notifications/:id/read`, `POST /notifications/read-all`. Bell
|
||||||
|
feed now reads real persisted rows with unread badge + mark-read.
|
||||||
|
Producer wired: deployment success/failure writes a notification.
|
||||||
|
Verified e2e: list, unread count, mark-one, mark-all. Further producers
|
||||||
|
(backup completion, agent offline, upgrade available) can reuse
|
||||||
|
`insertUserNotification`.
|
||||||
- [x] **GitHub App install surfacing**: Settings → Git Providers shows an
|
- [x] **GitHub App install surfacing**: Settings → Git Providers shows an
|
||||||
"Install GitHub App" action that opens `install_url`, and handles the
|
"Install GitHub App" action that opens `install_url`, and handles the
|
||||||
`?installation_id=` return by calling `POST /git/github-app/connect`.
|
`?installation_id=` return by calling `POST /git/github-app/connect`.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -368,12 +369,19 @@ func runDeploymentAndSync(
|
|||||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||||
time.Now(), service.ID,
|
time.Now(), service.ID,
|
||||||
)
|
)
|
||||||
|
insertUserNotification(db, userID, "deployment", "Deployment succeeded",
|
||||||
|
fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String())
|
||||||
return
|
return
|
||||||
case "failed":
|
case "failed":
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||||
time.Now(), service.ID,
|
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
|
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.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||||
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
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
|
// Node Agent routes
|
||||||
agentHandler.SetupRoutes(protected)
|
agentHandler.SetupRoutes(protected)
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,18 @@ type NodeAgent struct {
|
|||||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
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 {
|
type Project struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
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)
|
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
|
||||||
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
||||||
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (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)
|
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
|
||||||
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
|
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
|
||||||
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
|
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
|
||||||
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
||||||
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
||||||
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
||||||
|
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||||
@@ -50,10 +52,13 @@ type Querier interface {
|
|||||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
|
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
|
||||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||||
|
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||||
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
|
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
|
||||||
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]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)
|
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE IF NOT EXISTS public.notifications (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
kind character varying(50) NOT NULL,
|
||||||
|
title character varying(255) NOT NULL,
|
||||||
|
body text,
|
||||||
|
resource_type character varying(50),
|
||||||
|
resource_id character varying(255),
|
||||||
|
read_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone DEFAULT now(),
|
||||||
|
CONSTRAINT notifications_pkey PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON public.notifications(user_id, created_at DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS public.notifications;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 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);
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- name: CountUnreadNotificationsByUser :one
|
||||||
|
SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL;
|
||||||
|
|
||||||
|
-- name: MarkNotificationReadByIDAndUser :exec
|
||||||
|
UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at IS NULL;
|
||||||
|
|
||||||
|
-- name: MarkAllNotificationsReadByUser :exec
|
||||||
|
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL;
|
||||||
@@ -183,3 +183,15 @@ CREATE TABLE agent_heartbeats (
|
|||||||
version VARCHAR(50) NOT NULL DEFAULT '',
|
version VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind VARCHAR(50) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
resource_type VARCHAR(50),
|
||||||
|
resource_id VARCHAR(255),
|
||||||
|
read_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
@@ -21,8 +21,9 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getUpgradeStatus,
|
getUpgradeStatus,
|
||||||
listAuditLogs,
|
listNotifications,
|
||||||
listBuilds,
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead,
|
||||||
pullUpgradeImage,
|
pullUpgradeImage,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { signOutAuthSession } from '@/lib/auth-client';
|
import { signOutAuthSession } from '@/lib/auth-client';
|
||||||
@@ -64,15 +65,19 @@ export function PlatformShell() {
|
|||||||
queryFn: getUpgradeStatus,
|
queryFn: getUpgradeStatus,
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
});
|
});
|
||||||
const buildsQuery = useQuery({
|
const notificationsQuery = useQuery({
|
||||||
queryKey: ['shell-build-notifications'],
|
queryKey: ['shell-notifications'],
|
||||||
queryFn: () => listBuilds({ page: 1, limit: 5 }),
|
queryFn: () => listNotifications(20),
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
const auditQuery = useQuery({
|
const markReadMutation = useMutation({
|
||||||
queryKey: ['shell-audit-notifications'],
|
mutationFn: (id: string) => markNotificationRead(id),
|
||||||
queryFn: () => listAuditLogs({ page: 1, limit: 5 }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
||||||
enabled: !isDemoMode,
|
});
|
||||||
|
const markAllReadMutation = useMutation({
|
||||||
|
mutationFn: markAllNotificationsRead,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,19 +96,8 @@ export function PlatformShell() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const notifications = useMemo(() => {
|
const notifications = notificationsQuery.data?.notifications ?? [];
|
||||||
const builds = (buildsQuery.data?.builds ?? []).map((build) => ({
|
const unreadCount = notificationsQuery.data?.unread ?? 0;
|
||||||
id: `build-${build.id}`,
|
|
||||||
title: `${build.status} build`,
|
|
||||||
body: build.imageName || build.serviceId || build.id,
|
|
||||||
}));
|
|
||||||
const audits = (auditQuery.data ?? []).map((log) => ({
|
|
||||||
id: `audit-${log.id}`,
|
|
||||||
title: log.action,
|
|
||||||
body: `${log.resource}${log.resourceId ? ` / ${log.resourceId}` : ''}`,
|
|
||||||
}));
|
|
||||||
return [...builds, ...audits].slice(0, 6);
|
|
||||||
}, [auditQuery.data, buildsQuery.data?.builds]);
|
|
||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -248,8 +242,10 @@ export function PlatformShell() {
|
|||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
>
|
>
|
||||||
<Bell size={15} />
|
<Bell size={15} />
|
||||||
{notifications.length > 0 ? (
|
{unreadCount > 0 ? (
|
||||||
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-[var(--accent-primary)]" />
|
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -258,32 +254,54 @@ export function PlatformShell() {
|
|||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
|
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">Build and audit activity</p>
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{unreadCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => markAllReadMutation.mutate()}
|
||||||
|
className="rounded-[var(--radius-sm)] px-2 py-1 text-xs text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] })}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||||
|
aria-label="Refresh notifications"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['shell-build-notifications'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['shell-audit-notifications'] });
|
|
||||||
}}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
|
||||||
aria-label="Refresh notifications"
|
|
||||||
>
|
|
||||||
<RefreshCw size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="max-h-80 space-y-2 overflow-y-auto">
|
||||||
{notifications.length > 0 ? (
|
{notifications.length > 0 ? (
|
||||||
notifications.map((item) => (
|
notifications.map((item) => (
|
||||||
<div key={item.id} className="rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3">
|
<button
|
||||||
<p className="text-sm font-medium text-[var(--text-primary)]">{item.title}</p>
|
key={item.id}
|
||||||
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
|
type="button"
|
||||||
</div>
|
onClick={() => { if (!item.read_at) markReadMutation.mutate(item.id ?? ''); }}
|
||||||
|
className="w-full rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-left hover:bg-[var(--surface-raised)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{!item.read_at ? (
|
||||||
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent-primary)]" />
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`text-sm ${item.read_at ? 'text-[var(--text-secondary)]' : 'font-medium text-[var(--text-primary)]'}`}>{item.title}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
|
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
|
||||||
<CheckCircle2 size={15} className="text-[var(--success)]" />
|
<CheckCircle2 size={15} className="text-[var(--success)]" />
|
||||||
No recent events
|
No notifications yet
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1314,6 +1314,118 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/notifications": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List notifications for the current user */
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Notifications and unread count */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["NotificationListResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/notifications/{id}/read": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Mark a notification as read */
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Marked read */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MessageResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/notifications/read-all": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Mark all notifications as read */
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description All marked read */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MessageResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/audit-logs": {
|
"/audit-logs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -7030,6 +7142,23 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
|
Notification: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id?: string;
|
||||||
|
kind?: string;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
resource_type?: string;
|
||||||
|
resource_id?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
read_at?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
NotificationListResponse: {
|
||||||
|
notifications?: components["schemas"]["Notification"][];
|
||||||
|
unread?: number;
|
||||||
|
};
|
||||||
AuditLogListResponse: {
|
AuditLogListResponse: {
|
||||||
audit_logs?: components["schemas"]["AuditLog"][];
|
audit_logs?: components["schemas"]["AuditLog"][];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1427,3 +1427,25 @@ export async function listCronExecutions(id: string): Promise<CronExecutionEntit
|
|||||||
export async function triggerCronJob(id: string): Promise<void> {
|
export async function triggerCronJob(id: string): Promise<void> {
|
||||||
await requestJson(`/cron-jobs/${encodeURIComponent(id)}/trigger`, { method: 'POST' });
|
await requestJson(`/cron-jobs/${encodeURIComponent(id)}/trigger`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotificationEntity = components['schemas']['Notification'];
|
||||||
|
|
||||||
|
export type NotificationList = {
|
||||||
|
notifications: NotificationEntity[];
|
||||||
|
unread: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listNotifications(limit = 20): Promise<NotificationList> {
|
||||||
|
const payload = await requestJson<{ notifications?: NotificationEntity[]; unread?: number }>(
|
||||||
|
`/notifications?limit=${limit}`,
|
||||||
|
);
|
||||||
|
return { notifications: payload.notifications ?? [], unread: payload.unread ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationRead(id: string): Promise<void> {
|
||||||
|
await requestJson(`/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(): Promise<void> {
|
||||||
|
await requestJson('/notifications/read-all', { method: 'POST' });
|
||||||
|
}
|
||||||
@@ -897,6 +897,65 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
/notifications:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Notifications
|
||||||
|
summary: List notifications for the current user
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 20
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Notifications and unread count
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NotificationListResponse'
|
||||||
|
|
||||||
|
/notifications/{id}/read:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Notifications
|
||||||
|
summary: Mark a notification as read
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Marked read
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MessageResponse'
|
||||||
|
|
||||||
|
/notifications/read-all:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Notifications
|
||||||
|
summary: Mark all notifications as read
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: All marked read
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MessageResponse'
|
||||||
|
|
||||||
# Audit Logs
|
# Audit Logs
|
||||||
/audit-logs:
|
/audit-logs:
|
||||||
get:
|
get:
|
||||||
@@ -4740,6 +4799,40 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
description: Timestamp when the audit log entry was created
|
description: Timestamp when the audit log entry was created
|
||||||
|
|
||||||
|
Notification:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
resource_type:
|
||||||
|
type: string
|
||||||
|
resource_id:
|
||||||
|
type: string
|
||||||
|
read_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
NotificationListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
notifications:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Notification'
|
||||||
|
unread:
|
||||||
|
type: integer
|
||||||
|
|
||||||
AuditLogListResponse:
|
AuditLogListResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -6380,6 +6473,8 @@ tags:
|
|||||||
description: Service template catalog and deployment
|
description: Service template catalog and deployment
|
||||||
- name: Audit
|
- name: Audit
|
||||||
description: Audit event history and resource-level trails
|
description: Audit event history and resource-level trails
|
||||||
|
- name: Notifications
|
||||||
|
description: Persistent user notifications with read state
|
||||||
- name: Databases
|
- name: Databases
|
||||||
description: Database management
|
description: Database management
|
||||||
- name: Git
|
- name: Git
|
||||||
|
|||||||
Reference in new issue
Block a user