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

+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' });
}