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

52 lines
1.5 KiB
TypeScript

"use client";
import { useEffect } from "react";
export type WebEventMap = {
hello: { ts: number };
ping: { ts: number };
"session.qr": { accountId: string; ts: number };
"session.connected": { accountId: string; phoneNumber: string | null };
"session.disconnected": { accountId: string };
"session.timeout": { accountId: string };
"groups.synced": { accountId: string; count: number };
"reminder.fired": {
reminderId: string;
runId: string;
status: string;
sent?: number;
total?: number;
};
"reminder.failed": { reminderId: string; error: string };
"send_test.done": { groupId: string; ok: boolean; error: string | null };
};
type Handlers = { [K in keyof WebEventMap]?: (data: WebEventMap[K]) => void };
export function useEvents(handlers: Handlers): void {
useEffect(() => {
const es = new EventSource("/api/events");
const wired: { type: string; fn: (e: MessageEvent) => void }[] = [];
for (const type of Object.keys(handlers) as (keyof WebEventMap)[]) {
const h = handlers[type];
if (!h) continue;
const fn = (e: MessageEvent) => {
try {
(h as (data: unknown) => void)(JSON.parse(e.data));
} catch {
// ignore malformed
}
};
es.addEventListener(type, fn);
wired.push({ type, fn });
}
return () => {
for (const { type, fn } of wired) {
es.removeEventListener(type, fn);
}
es.close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}