Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion pm-stack/aggregator/src/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,17 @@ export interface QuietPrefs {
start: string;
end: string;
tz: string;
/** Drew 2026-11-01: quiet hours can be turned OFF entirely — no
* time-of-day limit at all. Absent (older rows) reads as on. */
enabled?: boolean;
}

// --- Pure helpers (unit-tested) ---------------------------------------------

/** Design §1: the incident class defaults ON for new subscriptions. */
export const DEFAULT_CLASSES: Record<string, boolean> = { incident: true };
/** Design §2: default quiet window 23:00–07:00 America/Chicago. */
export const DEFAULT_QUIET: QuietPrefs = { start: "23:00", end: "07:00", tz: "America/Chicago" };
export const DEFAULT_QUIET: QuietPrefs = { start: "23:00", end: "07:00", tz: "America/Chicago", enabled: true };

export function mergeClasses(input: unknown): Record<string, boolean> {
const out = { ...DEFAULT_CLASSES };
Expand All @@ -89,6 +92,7 @@ export function mergeQuiet(input: unknown): QuietPrefs {
if (typeof q.start === "string" && /^\d{2}:\d{2}$/.test(q.start)) out.start = q.start;
if (typeof q.end === "string" && /^\d{2}:\d{2}$/.test(q.end)) out.end = q.end;
if (typeof q.tz === "string" && q.tz.trim()) out.tz = q.tz.trim();
if (typeof q.enabled === "boolean") out.enabled = q.enabled;
}
return out;
}
Expand All @@ -102,6 +106,7 @@ export function classEnabled(sub: { classes: Record<string, boolean> }, cls: str
* The incident class never consults this (breakthrough class, #3676);
* it exists for the dashboard classes of #3317. */
export function inQuietHours(quiet: QuietPrefs, now: Date): boolean {
if (quiet.enabled === false) return false; // quiet hours off — no time-of-day limit
let mins: number;
try {
const parts = new Intl.DateTimeFormat("en-US", {
Expand Down Expand Up @@ -417,6 +422,62 @@ export class AlertsStore {
}
}

/** The test alert (#3676 follow-up, Drew 2026-11-01): the /alerts
* page's "Send a test alert" button. Clearly a test in title + body;
* objectRef is a stable non-incident ref so the sends ledger keeps
* test traffic distinguishable from incident traffic. */
export const TEST_OBJECT_REF = "test:push";

export function buildTestPayload(origin: string): PushPayload {
return {
class: "test",
objectRef: TEST_OBJECT_REF,
url: `${origin}/alerts`,
title: "Test alert — PM Stack",
body: "This is a test alert from the Alerts page. Push delivery works on this device.",
};
}

export interface TestPushSummary {
subscriptions: number;
sent: number;
gone: number;
errors: number;
}

/**
* Send the test push to every current subscription, recording one
* notification_sends row (class 'test') per subscription. The sender
* is injectable so the sends-row behavior is unit-testable without a
* network; production uses the real Web Push sender.
*/
export async function sendTestPush(
store: Pick<AlertsStore, "listSubscriptions" | "logSend" | "noteSubscriptionGone" | "noteSubscriptionAlive">,
origin: string,
send: typeof sendPush = sendPush,
): Promise<TestPushSummary> {
const payload = buildTestPayload(origin);
const subs = await store.listSubscriptions();
const summary: TestPushSummary = { subscriptions: subs.length, sent: 0, gone: 0, errors: 0 };
for (const sub of subs) {
const target: PushTarget = { endpoint: sub.endpoint, p256dh: sub.p256dh, auth: sub.auth };
const outcome = await send(target, payload);
if (outcome.ok) {
summary.sent += 1;
await store.logSend({ class: "test", objectRef: TEST_OBJECT_REF, outcome: "sent" });
await store.noteSubscriptionAlive(sub.endpoint);
} else if (outcome.gone) {
summary.gone += 1;
await store.logSend({ class: "test", objectRef: TEST_OBJECT_REF, outcome: "gone" });
await store.noteSubscriptionGone(sub.endpoint);
} else {
summary.errors += 1;
await store.logSend({ class: "test", objectRef: TEST_OBJECT_REF, outcome: "error" });
}
}
return summary;
}

let storePromise: Promise<AlertsStore | null> | null = null;

/** The shared store; null when the Supabase path is unavailable. */
Expand Down
59 changes: 50 additions & 9 deletions pm-stack/aggregator/src/assets/push-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,65 @@
return navigator.serviceWorker.ready;
}

function isIOS() {
return (
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
);
}

function isStandalone() {
return (
(window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) ||
window.navigator.standalone === true
);
}

// Coded errors (Drew 2026-11-01): callers translate codes into plain
// words. A raw Notification.permission value or exception text must
// never reach the page as the message — the old build threw
// "notification permission: default" and the page alert()ed it.
function pushError(code) {
var e = new Error(code);
e.code = code;
return e;
}

window.pmPush = {
supported: function () {
return "PushManager" in window && "Notification" in window;
},
platform: function () {
return { ios: isIOS(), standalone: isStandalone() };
},
permission: function () {
return "Notification" in window ? Notification.permission : "unsupported";
},
current: async function () {
var reg = await ready();
return reg.pushManager.getSubscription();
},
subscribe: async function (vapidPublicKey, prefs) {
var permission = await Notification.requestPermission();
if (permission !== "granted") throw new Error("notification permission: " + permission);
if (!window.pmPush.supported()) throw pushError("unsupported");
// The permission request happens here, synchronously downstream
// of the Enable tap, so the browser attributes the prompt to
// the user's gesture.
if (Notification.permission === "denied") throw pushError("denied");
var permission =
Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
if (permission === "denied") throw pushError("denied");
if (permission !== "granted") throw pushError("dismissed");
var reg = await ready();
var sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
try {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
} catch (e) {
throw pushError("subscribe-failed");
}
}
var json = sub.toJSON();
var res = await fetch("/api/push/subscriptions", {
Expand All @@ -85,12 +126,12 @@
userAgent: navigator.userAgent,
}),
});
if (!res.ok) throw new Error("subscription save failed: HTTP " + res.status);
if (!res.ok) throw pushError("save-failed");
return sub;
},
updatePrefs: async function (prefs) {
var sub = await window.pmPush.current();
if (!sub) throw new Error("not subscribed on this browser");
if (!sub) throw pushError("not-subscribed");
var json = sub.toJSON();
var res = await fetch("/api/push/subscriptions", {
method: "POST",
Expand All @@ -103,7 +144,7 @@
userAgent: navigator.userAgent,
}),
});
if (!res.ok) throw new Error("preference save failed: HTTP " + res.status);
if (!res.ok) throw pushError("save-failed");
},
unsubscribe: async function () {
var sub = await window.pmPush.current();
Expand Down
5 changes: 4 additions & 1 deletion pm-stack/aggregator/src/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ export const PAGES: PageDef[] = [
blurb:
"The alert throat (#3676): sentinel incidents, Web Push delivery + one escalation, ack/close, the Supabase ledger.",
group: "ops",
nav: "dropdown",
// Drew 2026-11-01: primary — the ack surface must be one tap from
// the bar, not buried in the Pages dropdown (supersedes the
// post-#1497 dropdown placement recorded in curation-1497.test).
nav: "primary",
},
{
id: "pages",
Expand Down
Loading