cm_whatsapp_bot_v1/apps/web/src/lib/notifications.ts
yiekheng 8f2ee5df9e feat(web): browser notifications for reminder + send-test events
In-tab notification bridge so the operator gets a system notification
when a reminder fires successfully (or partly / fails) and when a
send-test message lands. Foundation for true background push later
(VAPID + service-worker subscription); this lands the wiring so
behaviour is testable today.

Pieces
------
- `lib/notifications.ts` — pure helper module:
    * notificationSupport / getPermission — feature detection that
      treats the SSR / unsupported-browser case as "denied" so callers
      don't have to handle a third state.
    * isOptedIn / setOptedIn — localStorage-backed opt-in flag
      (key `cmbot.notifications.optedIn`). Survives gracefully when
      window is missing or storage throws (private mode / quota).
    * showNotification(opts) — gated dispatch returning a discriminated
      result ({ ok: true, tag } | { ok: false, reason }) so callers
      can fall back to a UI toast on opt-out / unsupported / error.
    * reminderFiredToNotification + sendTestDoneToNotification —
      pure mappers from the bot's SSE events into notification args.
      Skips bookkeeping noise (status === "skipped") and failures
      that the in-page toast already shows verbatim.

- `components/notification-manager.tsx` — client component mounted
  once at the app shell. Subscribes to `reminder.fired` and
  `send_test.done` via useEvents and forwards each through the pure
  mappers. Renders no DOM.

- `components/notifications-toggle.tsx` — settings-page card with
  three states (unsupported / not-granted / granted+opted-in).
  "Send test" button fires a sample notification so the operator
  can verify the wiring without waiting for a real reminder. The
  blocked-by-browser path points them at site settings instead of
  silently doing nothing.

- `app/settings/page.tsx` — new "Notifications" card sits above
  the Appearance card.

- `app/layout.tsx` — `<NotificationManager />` rendered alongside
  `<Toaster />` inside ThemeProvider so the SSE subscription is
  active across all routes.

Bot side
--------
- `apps/bot/src/scheduler/fire-reminder.ts` — emits
  `pgNotifyWeb({ type: "reminder.fired", reminderId, runId, status })`
  after every run regardless of success/partial/failed. The web
  side decides whether to surface it as a notification (skipped is
  filtered out client-side).

- send_test.done was already emitted by `ipc/send-test-handler.ts`.

PWA service-worker tests (the original ask before this thread)
--------------------------------------------------------------
- Extracted the Serwist config into `pwa/config.ts` so the choices
  (skipWaiting, clientsClaim, navigationPreload, runtimeCaching,
  precacheEntries) are pinnable without booting a worker scope.
- 6 tests in `pwa/config.test.ts` lock the surface (no extra keys
  appear silently, the manifest passes through unchanged, the
  pinned booleans stay where production expects them).
- 6 tests in `app/manifest.webmanifest/route.test.ts` cover the
  manifest contract (display=standalone, start_url=/, dark theme
  colors match the OS, both icons are PNG + maskable, paths
  match committed PNGs in public/).

Test counts
-----------
281 web + 31 shared + 26 bot = 338 total (was 306).

  - +6 pwa/config (service-worker config pinning)
  - +6 app/manifest.webmanifest (PWA manifest contract)
  - +20 lib/notifications (full coverage of mappers + dispatch
        gates + SSR / unsupported / blocked / opted-out paths)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:30:40 +08:00

183 lines
5.8 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;
}): ShowNotificationOptions | null {
if (event.status === "skipped") return null;
const headline =
event.status === "success"
? "Reminder sent"
: event.status === "partial"
? "Reminder partly sent"
: "Reminder failed";
const body =
event.status === "success"
? "All groups received the message."
: event.status === "partial"
? "Some groups received the message; others failed. See activity."
: "No groups received the message. See activity.";
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}`,
};
}