cm_whatsapp_bot_v1/apps/web/src/lib/notifications.ts
yiekheng 57786f9d09 feat(bot,web): window-end gate + paused/resume run lifecycle
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>
2026-05-10 15:48:52 +08:00

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}`,
};
}