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

+7 -2
View File
@@ -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*
ported — Better Auth owns identity, Containr owns deployments and
databases natively.
- [ ] **Notifications depth**: notification center persistence (read/unread),
not just a transient bell feed.
- [x] **Notifications depth**: `notifications` table + `GET /notifications`,
`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
"Install GitHub App" action that opens `install_url`, and handles the
`?installation_id=` return by calling `POST /git/github-app/connect`.
+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
@@ -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;
+12
View File
@@ -183,3 +183,15 @@ CREATE TABLE agent_heartbeats (
version VARCHAR(50) NOT NULL DEFAULT '',
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()
);
+61 -43
View File
@@ -1,5 +1,5 @@
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 {
Bell,
@@ -21,8 +21,9 @@ import {
} from 'lucide-react';
import {
getUpgradeStatus,
listAuditLogs,
listBuilds,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
pullUpgradeImage,
} from '@/lib/api-client';
import { signOutAuthSession } from '@/lib/auth-client';
@@ -64,15 +65,19 @@ export function PlatformShell() {
queryFn: getUpgradeStatus,
enabled: !isDemoMode,
});
const buildsQuery = useQuery({
queryKey: ['shell-build-notifications'],
queryFn: () => listBuilds({ page: 1, limit: 5 }),
const notificationsQuery = useQuery({
queryKey: ['shell-notifications'],
queryFn: () => listNotifications(20),
enabled: !isDemoMode,
refetchInterval: 30_000,
});
const auditQuery = useQuery({
queryKey: ['shell-audit-notifications'],
queryFn: () => listAuditLogs({ page: 1, limit: 5 }),
enabled: !isDemoMode,
const markReadMutation = useMutation({
mutationFn: (id: string) => markNotificationRead(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
});
const markAllReadMutation = useMutation({
mutationFn: markAllNotificationsRead,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
});
useEffect(() => {
@@ -91,19 +96,8 @@ export function PlatformShell() {
},
});
const notifications = useMemo(() => {
const builds = (buildsQuery.data?.builds ?? []).map((build) => ({
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 notifications = notificationsQuery.data?.notifications ?? [];
const unreadCount = notificationsQuery.data?.unread ?? 0;
const signOut = async () => {
try {
@@ -248,8 +242,10 @@ export function PlatformShell() {
aria-label="Notifications"
>
<Bell size={15} />
{notifications.length > 0 ? (
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-[var(--accent-primary)]" />
{unreadCount > 0 ? (
<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}
</button>
@@ -258,32 +254,54 @@ export function PlatformShell() {
<div className="mb-2 flex items-center justify-between">
<div>
<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>
<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 className="space-y-2">
<div className="max-h-80 space-y-2 overflow-y-auto">
{notifications.length > 0 ? (
notifications.map((item) => (
<div key={item.id} className="rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3">
<p className="text-sm font-medium text-[var(--text-primary)]">{item.title}</p>
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
</div>
<button
key={item.id}
type="button"
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)]">
<CheckCircle2 size={15} className="text-[var(--success)]" />
No recent events
No notifications yet
</div>
)}
</div>
+129
View File
@@ -1314,6 +1314,118 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -7030,6 +7142,23 @@ export interface components {
*/
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: {
audit_logs?: components["schemas"]["AuditLog"][];
};
+22
View File
@@ -1427,3 +1427,25 @@ export async function listCronExecutions(id: string): Promise<CronExecutionEntit
export async function triggerCronJob(id: string): Promise<void> {
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' });
}
+95
View File
@@ -897,6 +897,65 @@ paths:
schema:
$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:
get:
@@ -4740,6 +4799,40 @@ components:
format: date-time
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:
type: object
properties:
@@ -6380,6 +6473,8 @@ tags:
description: Service template catalog and deployment
- name: Audit
description: Audit event history and resource-level trails
- name: Notifications
description: Persistent user notifications with read state
- name: Databases
description: Database management
- name: Git