fire-reminder.ts now: * Computes windowEnd via @cmbot/shared/windowEndAt(timezone, endHour, now). Per-target loop trips the gate before sending; pending rows are LEFT pending (not flipped to skipped) so the run is resumable. * Accepts an optional runId on the FireReminderPayload. When set, the handler ATTACHES to that existing run instead of creating a new one and only re-tries pending targets. Resume is allowed even when the reminder.status is 'paused' (otherwise we couldn't drag it back into delivery). * Final-status logic adds a 'paused' branch (window closed mid-run with at least one row still pending AND something delivered); failed when window closed before any send; partial / success otherwise. * Lifecycle: a paused run flips the reminder row to status='paused' and skips the recurring re-arm. Resuming or completing later flips it back to 'active'. * SSE event payload gains optional sent/total counts. reminderFiredToNotification picks up: * New 'paused' headline + 'X of Y groups delivered. Tap to resume or cancel.' body. * 'partial' body uses sent/total when present. WebEventMap and the bot's WebEvent union match the new shape. Tests: * fire-reminder.test.ts gains a "resume against paused reminder acquires mutex" case. * notifications.test.ts gains 3 paused/partial-sent body cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
/**
|
|
* Browser Notification helper. Pure-ish wrapper around the Web
|
|
* Notification API plus a localStorage flag for the operator's
|
|
* opt-in preference. We use this for surfaced, in-tab notifications
|
|
* — when a tab is open, fire a notification on:
|
|
*
|
|
* - reminder.fired (status=success / partial)
|
|
* - send_test.done (ok=true)
|
|
*
|
|
* For background push (closed app) we'd need a service-worker
|
|
* subscription + VAPID, which is a bigger lift. This module is the
|
|
* foundation that swap-in lands on later.
|
|
*/
|
|
|
|
const STORAGE_KEY = "cmbot.notifications.optedIn";
|
|
|
|
export type NotificationPermission = "default" | "granted" | "denied";
|
|
export type NotificationCapability = "supported" | "unsupported";
|
|
|
|
/** Detect whether the browser exposes the Notification API at all. */
|
|
export function notificationSupport(): NotificationCapability {
|
|
if (typeof window === "undefined") return "unsupported";
|
|
if (typeof window.Notification === "undefined") return "unsupported";
|
|
return "supported";
|
|
}
|
|
|
|
/** Read the current permission state, treating unsupported browsers
|
|
* as "denied" so callers don't have to handle a third state. */
|
|
export function getPermission(): NotificationPermission {
|
|
if (notificationSupport() === "unsupported") return "denied";
|
|
return window.Notification.permission as NotificationPermission;
|
|
}
|
|
|
|
/** Has the operator opted in (saved in localStorage)? */
|
|
export function isOptedIn(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
try {
|
|
return window.localStorage.getItem(STORAGE_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function setOptedIn(value: boolean): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
if (value) {
|
|
window.localStorage.setItem(STORAGE_KEY, "1");
|
|
} else {
|
|
window.localStorage.removeItem(STORAGE_KEY);
|
|
}
|
|
} catch {
|
|
// localStorage can throw in private mode / quota — non-fatal.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ask the browser for permission. Resolves with the resulting
|
|
* permission state. Idempotent if already granted/denied.
|
|
*/
|
|
export async function requestPermission(): Promise<NotificationPermission> {
|
|
if (notificationSupport() === "unsupported") return "denied";
|
|
const result = await window.Notification.requestPermission();
|
|
return result as NotificationPermission;
|
|
}
|
|
|
|
export interface ShowNotificationOptions {
|
|
title: string;
|
|
body?: string;
|
|
/** Stable identifier so repeats of the same event coalesce. */
|
|
tag?: string;
|
|
/** Path the click handler should navigate to (page reuses or
|
|
* opens a new tab). Optional. */
|
|
href?: string;
|
|
/** Override the default icon; defaults to `/icon-192.png`. */
|
|
icon?: string;
|
|
}
|
|
|
|
export type NotificationDispatch =
|
|
| { ok: true; tag: string | undefined }
|
|
| { ok: false; reason: "unsupported" | "permission" | "opted-out" | "error"; error?: string };
|
|
|
|
/**
|
|
* Show a notification if everything's lined up:
|
|
* - browser supports it
|
|
* - operator has opted in
|
|
* - permission is granted
|
|
*
|
|
* Returns a discriminated result so callers can decide whether to
|
|
* fall back to a UI toast.
|
|
*/
|
|
export function showNotification(opts: ShowNotificationOptions): NotificationDispatch {
|
|
if (notificationSupport() === "unsupported") {
|
|
return { ok: false, reason: "unsupported" };
|
|
}
|
|
if (!isOptedIn()) {
|
|
return { ok: false, reason: "opted-out" };
|
|
}
|
|
if (getPermission() !== "granted") {
|
|
return { ok: false, reason: "permission" };
|
|
}
|
|
try {
|
|
const n = new window.Notification(opts.title, {
|
|
body: opts.body,
|
|
tag: opts.tag,
|
|
icon: opts.icon ?? "/icon-192.png",
|
|
badge: "/icon-192.png",
|
|
});
|
|
if (opts.href) {
|
|
n.onclick = () => {
|
|
try {
|
|
window.focus();
|
|
window.location.href = opts.href!;
|
|
} catch {
|
|
// ignore — focus can fail under iframe sandboxing
|
|
}
|
|
};
|
|
}
|
|
return { ok: true, tag: opts.tag };
|
|
} catch (err) {
|
|
return { ok: false, reason: "error", error: (err as Error).message };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Event → notification mapping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Map a `reminder.fired` SSE event into the notification arguments
|
|
* the manager component should call `showNotification` with.
|
|
*
|
|
* Returns null when the run isn't worth notifying about (e.g.
|
|
* `skipped` runs are bookkeeping noise, not user-facing events).
|
|
*/
|
|
export function reminderFiredToNotification(event: {
|
|
type: "reminder.fired";
|
|
reminderId: string;
|
|
runId: string;
|
|
status: string;
|
|
sent?: number;
|
|
total?: number;
|
|
}): ShowNotificationOptions | null {
|
|
if (event.status === "skipped") return null;
|
|
const headline =
|
|
event.status === "success"
|
|
? "Reminder sent"
|
|
: event.status === "paused"
|
|
? "Reminder paused"
|
|
: event.status === "partial"
|
|
? "Reminder partly sent"
|
|
: "Reminder failed";
|
|
let body =
|
|
event.status === "success"
|
|
? "All groups received the message."
|
|
: event.status === "paused"
|
|
? "Delivery window closed before all groups got the message."
|
|
: event.status === "partial"
|
|
? "Some groups received the message; others failed. See activity."
|
|
: "No groups received the message. See activity.";
|
|
if (event.status === "paused" && event.sent !== undefined && event.total !== undefined) {
|
|
body = `${event.sent} of ${event.total} groups delivered. Tap to resume or cancel.`;
|
|
} else if (
|
|
event.status === "partial" &&
|
|
event.sent !== undefined &&
|
|
event.total !== undefined
|
|
) {
|
|
body = `${event.sent} of ${event.total} groups delivered. See activity for details.`;
|
|
}
|
|
return {
|
|
title: headline,
|
|
body,
|
|
// Coalesce repeat fires of the same reminder on the same tab —
|
|
// only the most recent one stays visible.
|
|
tag: `reminder:${event.reminderId}`,
|
|
href: `/reminders/${event.reminderId}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map a `send_test.done` SSE event into notification arguments.
|
|
* Returns null on the failure case so the in-page toast (which
|
|
* shows the error verbatim) stays the source of truth.
|
|
*/
|
|
export function sendTestDoneToNotification(event: {
|
|
type: "send_test.done";
|
|
groupId: string;
|
|
ok: boolean;
|
|
}): ShowNotificationOptions | null {
|
|
if (!event.ok) return null;
|
|
return {
|
|
title: "Test message sent",
|
|
body: "WhatsApp delivered the test message.",
|
|
tag: `send-test:${event.groupId}`,
|
|
href: `/groups/${event.groupId}`,
|
|
};
|
|
}
|