diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx index af95e1a8..6cb9202f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx @@ -11,10 +11,12 @@ import { Inbox, Square, Trash2, + Bell, } from 'lucide-react'; import { WorkspaceLayout } from 'officerdev'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { useClient } from 'hooks/useClient'; +import { toast } from 'sonner'; import { useDashboardState } from 'state/useDashboardState'; import { Card } from '@/components/Card'; import { ScriptJobDetail } from './ScriptJobDetail'; @@ -138,6 +140,47 @@ const PanelHeader = ({ children }: { children: ReactNode }) => ( ); // Top-left panel: running first, then the FIFO queue (oldest pending on top — next to run). +// TEMPORARY. The first producer for officer-notify, so the pipe can be exercised from the UI before any +// real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord +// today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it. +const TestNotificationButton = () => { + const client = useClient(); + const [sending, setSending] = useState(false); + + const send = async () => { + setSending(true); + try { + // userId is omitted deliberately: the sidecar takes it from the header the proxy injects. + const res = await client.post<{ results?: { channel: string; sent: number; failed: number }[] }>( + '/notify/_officer/notify', + { type: 'test' }, + ); + const results = res?.results ?? []; + if (results.length === 0) { + toast.warning('No channels configured'); + } else { + toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', ')); + } + } catch { + toast.error('Failed to send notification'); + } finally { + setSending(false); + } + }; + + return ( + + ); +}; + const ActiveJobsPanel = () => { const { jobs, isLoading, act } = useJobsData(); const active = [ @@ -150,6 +193,7 @@ const ActiveJobsPanel = () => {

Running & Queued

{active.length > 0 && {active.length}} +
{isLoading ? ( diff --git a/src/servers/sidecar/notify/index.ts b/src/servers/sidecar/notify/index.ts index 72e745e9..9502ffda 100644 --- a/src/servers/sidecar/notify/index.ts +++ b/src/servers/sidecar/notify/index.ts @@ -61,13 +61,16 @@ const server = Bun.serve({ if (!body.type || !VALID_TYPES.includes(body.type)) { return Response.json({ error: `type must be one of ${VALID_TYPES.join(', ')}` }, { status: 400 }); } - // Producers inside the tailnet are trusted, but a userId typo would silently notify nobody, so it - // is required rather than defaulted. - if (typeof body.userId !== 'number') { - return Response.json({ error: 'userId is required' }, { status: 400 }); + // Producers inside the tailnet POST directly and say who to notify. A browser reaching this + // through /api/notify cannot know its own id, so the proxy's injected header stands in — the + // platform already authenticated whoever sent it. + const headerUser = Number(req.headers.get('X-Officer-User')); + const userId = typeof body.userId === 'number' ? body.userId : headerUser; + if (!Number.isFinite(userId) || userId <= 0) { + return Response.json({ error: 'userId is required (body or X-Officer-User)' }, { status: 400 }); } - const results = await dispatch(body as Notification); + const results = await dispatch({ ...body, userId } as Notification); return Response.json({ ok: true, results }); }