diff --git a/ROADMAP.md b/ROADMAP.md index 2ebdce7..d0b4071 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`. diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index e05c5b3..e517e93 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -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 } } diff --git a/app/backend/internal/api/notifications.go b/app/backend/internal/api/notifications.go new file mode 100644 index 0000000..801b963 --- /dev/null +++ b/app/backend/internal/api/notifications.go @@ -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"}) +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 70990be..37c49c4 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -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) diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 269963e..8c8ed01 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -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"` diff --git a/app/backend/internal/database/sqlcdb/notifications.sql.go b/app/backend/internal/database/sqlcdb/notifications.sql.go new file mode 100644 index 0000000..8fcc9c0 --- /dev/null +++ b/app/backend/internal/database/sqlcdb/notifications.sql.go @@ -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 +} diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 3e5b83e..7d03186 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -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 diff --git a/app/backend/migrations_goose/20260920000000_notifications.sql b/app/backend/migrations_goose/20260920000000_notifications.sql new file mode 100644 index 0000000..43909ea --- /dev/null +++ b/app/backend/migrations_goose/20260920000000_notifications.sql @@ -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; diff --git a/app/backend/sqlc/queries/notifications.sql b/app/backend/sqlc/queries/notifications.sql new file mode 100644 index 0000000..d9f8ef8 --- /dev/null +++ b/app/backend/sqlc/queries/notifications.sql @@ -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; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 76080c2..67a38c7 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -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() +); diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index 330f5d2..a9f4980 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -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" > - {notifications.length > 0 ? ( - + {unreadCount > 0 ? ( + + {unreadCount > 99 ? '99+' : unreadCount} + ) : null} @@ -258,32 +254,54 @@ export function PlatformShell() {

Notifications

-

Build and audit activity

+

+ {unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'} +

+
+
+ {unreadCount > 0 ? ( + + ) : null} +
-
-
+
{notifications.length > 0 ? ( notifications.map((item) => ( -
-

{item.title}

-

{item.body}

-
+ )) ) : (
- No recent events + No notifications yet
)}
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index b8ac4f6..bce01de 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -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"][]; }; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 19c9603..34abe55 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1427,3 +1427,25 @@ export async function listCronExecutions(id: string): Promise { 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 { + 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 { + await requestJson(`/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' }); +} + +export async function markAllNotificationsRead(): Promise { + await requestJson('/notifications/read-all', { method: 'POST' }); +} diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ae10ff5..c0e4acc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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