From dc1b0636bfbd9c4ff8daa4360f583eeb99dd94bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 31 Jul 2026 22:36:16 +0000 Subject: [PATCH] notify: a temporary test button on /jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first producer, so the pipe can be exercised from the UI before any real event is wired to it. Marked TEMPORARY in the source and meant to be deleted when a real producer replaces it. POST /_officer/notify now takes the user from the X-Officer-User header the proxy injects when the body omits it. A producer inside the tailnet says who to notify; a browser reaching this through /api/notify cannot know its own id, and the platform has already authenticated whoever sent it. Body still wins where present. The button sends { type: 'test' }, which fans out to every configured channel — Discord today, APNs and FCM the moment their credentials exist — and toasts what each one reported, so "no channels configured" is distinguishable from "sent". Co-Authored-By: Claude Opus 5 (1M context) --- .../Screens/Dashboard/Jobs/JobsPage.tsx | 44 +++++++++++++++++++ src/servers/sidecar/notify/index.ts | 13 +++--- 2 files changed, 52 insertions(+), 5 deletions(-) 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 }); }