notify: a temporary test button on /jobs
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={sending}
|
||||
title="Send a test notification to every configured channel"
|
||||
className="ml-auto shrink-0 flex items-center gap-1 text-xs font-medium text-duck-dark/50 hover:text-duck-teal cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
{sending ? 'Sending…' : 'Test notify'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveJobsPanel = () => {
|
||||
const { jobs, isLoading, act } = useJobsData();
|
||||
const active = [
|
||||
@@ -150,6 +193,7 @@ const ActiveJobsPanel = () => {
|
||||
<PanelHeader>
|
||||
<h2 className="text-sm font-semibold text-duck-dark">Running & Queued</h2>
|
||||
{active.length > 0 && <span className="text-xs text-duck-dark/40">{active.length}</span>}
|
||||
<TestNotificationButton />
|
||||
</PanelHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user