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>
52 lines
1.5 KiB
TypeScript
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
|
|
}, []);
|
|
}
|