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>
This commit is contained in:
parent
670eaf493c
commit
57786f9d09
@ -11,7 +11,17 @@ export type WebEvent =
|
|||||||
| { type: "session.disconnected"; accountId: string }
|
| { type: "session.disconnected"; accountId: string }
|
||||||
| { type: "session.timeout"; accountId: string }
|
| { type: "session.timeout"; accountId: string }
|
||||||
| { type: "groups.synced"; accountId: string; count: number }
|
| { type: "groups.synced"; accountId: string; count: number }
|
||||||
| { type: "reminder.fired"; reminderId: string; runId: string; status: string }
|
| {
|
||||||
|
type: "reminder.fired";
|
||||||
|
reminderId: string;
|
||||||
|
runId: string;
|
||||||
|
status: string;
|
||||||
|
// Optional delivered/total counts so the web side can render
|
||||||
|
// "X of Y groups delivered" in the paused-status notification
|
||||||
|
// body. Omitted on terminal-status events that don't need them.
|
||||||
|
sent?: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
| { type: "reminder.failed"; reminderId: string; error: string }
|
| { type: "reminder.failed"; reminderId: string; error: string }
|
||||||
// The web action enqueues a send_test via pg_notify and shows
|
// The web action enqueues a send_test via pg_notify and shows
|
||||||
// "Sending…" optimistically. This event closes the loop.
|
// "Sending…" optimistically. This event closes the loop.
|
||||||
|
|||||||
@ -18,13 +18,24 @@ const getReminderMock = vi.fn();
|
|||||||
vi.mock("../reminders/crud.js", () => ({
|
vi.mock("../reminders/crud.js", () => ({
|
||||||
getReminderWithDetails: (...args: unknown[]) => getReminderMock(...args),
|
getReminderWithDetails: (...args: unknown[]) => getReminderMock(...args),
|
||||||
}));
|
}));
|
||||||
|
// Drizzle's chainable query builders are mocked just deeply enough to
|
||||||
|
// let fire-reminder's happy path (and the resume path) walk through.
|
||||||
|
const findExistingRunMock = vi.fn();
|
||||||
vi.mock("../db.js", () => ({
|
vi.mock("../db.js", () => ({
|
||||||
db: {
|
db: {
|
||||||
insert: () => ({ values: () => ({ returning: async () => [{ id: "run-1" }] }) }),
|
insert: () => ({
|
||||||
|
values: () => ({
|
||||||
|
returning: async () => [{ id: "run-1" }],
|
||||||
|
}),
|
||||||
|
// Targets path: no .returning() chained.
|
||||||
|
values_no_returning: async () => undefined,
|
||||||
|
}),
|
||||||
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
||||||
query: {
|
query: {
|
||||||
whatsappGroups: { findMany: async () => [] },
|
whatsappGroups: { findMany: async () => [] },
|
||||||
mediaFiles: { findMany: async () => [] },
|
mediaFiles: { findMany: async () => [] },
|
||||||
|
reminderRunTargets: { findMany: async () => [] },
|
||||||
|
reminderRuns: { findFirst: (...args: unknown[]) => findExistingRunMock(...args) },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -56,6 +67,8 @@ describe("fireReminder", () => {
|
|||||||
scheduleKind: "one_off",
|
scheduleKind: "one_off",
|
||||||
rrule: null,
|
rrule: null,
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
|
deliveryWindowStartHour: 6,
|
||||||
|
deliveryWindowEndHour: 18,
|
||||||
name: "Test",
|
name: "Test",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -76,6 +89,8 @@ describe("fireReminder", () => {
|
|||||||
scheduleKind: "one_off",
|
scheduleKind: "one_off",
|
||||||
rrule: null,
|
rrule: null,
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
|
deliveryWindowStartHour: 6,
|
||||||
|
deliveryWindowEndHour: 18,
|
||||||
name: "Test",
|
name: "Test",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -92,6 +107,33 @@ describe("fireReminder", () => {
|
|||||||
expect(accountMutex.run).not.toHaveBeenCalled();
|
expect(accountMutex.run).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("DOES acquire the mutex on a resume even when the reminder is paused", async () => {
|
||||||
|
// Resume path must allow status='paused' (and 'active') so the
|
||||||
|
// operator can drag a paused reminder back into delivery. Fresh
|
||||||
|
// fires still require status='active'; that's covered by the
|
||||||
|
// earlier "inactive" test.
|
||||||
|
getReminderMock.mockResolvedValue({
|
||||||
|
id: "r-1",
|
||||||
|
accountId: "acct-A",
|
||||||
|
status: "paused",
|
||||||
|
targets: [],
|
||||||
|
messages: [],
|
||||||
|
createdBy: "op-1",
|
||||||
|
scheduleKind: "one_off",
|
||||||
|
rrule: null,
|
||||||
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
|
deliveryWindowStartHour: 6,
|
||||||
|
deliveryWindowEndHour: 18,
|
||||||
|
name: "Test",
|
||||||
|
});
|
||||||
|
findExistingRunMock.mockResolvedValue({ id: "run-existing" });
|
||||||
|
|
||||||
|
await fireReminder({ reminderId: "r-1", runId: "run-existing" });
|
||||||
|
|
||||||
|
expect(accountMutex.run).toHaveBeenCalledTimes(1);
|
||||||
|
expect(accountMutex.run).toHaveBeenCalledWith("acct-A", expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
it("uses different mutex keys for different accounts (cross-account isolation)", async () => {
|
it("uses different mutex keys for different accounts (cross-account isolation)", async () => {
|
||||||
getReminderMock.mockResolvedValueOnce({
|
getReminderMock.mockResolvedValueOnce({
|
||||||
id: "r-A",
|
id: "r-A",
|
||||||
@ -103,6 +145,8 @@ describe("fireReminder", () => {
|
|||||||
scheduleKind: "one_off",
|
scheduleKind: "one_off",
|
||||||
rrule: null,
|
rrule: null,
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
|
deliveryWindowStartHour: 6,
|
||||||
|
deliveryWindowEndHour: 18,
|
||||||
name: "A",
|
name: "A",
|
||||||
});
|
});
|
||||||
getReminderMock.mockResolvedValueOnce({
|
getReminderMock.mockResolvedValueOnce({
|
||||||
@ -115,6 +159,8 @@ describe("fireReminder", () => {
|
|||||||
scheduleKind: "one_off",
|
scheduleKind: "one_off",
|
||||||
rrule: null,
|
rrule: null,
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
|
deliveryWindowStartHour: 6,
|
||||||
|
deliveryWindowEndHour: 18,
|
||||||
name: "B",
|
name: "B",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,12 @@ import { readFile } from "node:fs/promises";
|
|||||||
import { db } from "../db.js";
|
import { db } from "../db.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { sessionManager } from "../whatsapp/session-manager.js";
|
import { sessionManager } from "../whatsapp/session-manager.js";
|
||||||
import { absoluteMediaPath, nextOccurrence, resolveDeliveryKind } from "@cmbot/shared";
|
import {
|
||||||
|
absoluteMediaPath,
|
||||||
|
nextOccurrence,
|
||||||
|
resolveDeliveryKind,
|
||||||
|
windowEndAt,
|
||||||
|
} from "@cmbot/shared";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { writeAuditLog } from "../audit.js";
|
import { writeAuditLog } from "../audit.js";
|
||||||
import { getReminderWithDetails } from "../reminders/crud.js";
|
import { getReminderWithDetails } from "../reminders/crud.js";
|
||||||
@ -23,7 +28,13 @@ import { accountMutex } from "./per-key-mutex.js";
|
|||||||
import { accountRateLimiter } from "./rate-limiter.js";
|
import { accountRateLimiter } from "./rate-limiter.js";
|
||||||
import { MediaUploadCache } from "./media-upload-cache.js";
|
import { MediaUploadCache } from "./media-upload-cache.js";
|
||||||
|
|
||||||
export type FireReminderPayload = { reminderId: string };
|
export type FireReminderPayload = {
|
||||||
|
reminderId: string;
|
||||||
|
/** Optional resume hook. When present, fire-reminder ATTACHES to
|
||||||
|
* the existing run instead of creating a new one and only re-tries
|
||||||
|
* targets in `pending` status. Set by the resume server action. */
|
||||||
|
runId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** Random delay between same-group message parts. Just enough for
|
/** Random delay between same-group message parts. Just enough for
|
||||||
* visible ordering in the chat at WA's natural pace. */
|
* visible ordering in the chat at WA's natural pace. */
|
||||||
@ -64,39 +75,74 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
|
|||||||
logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found");
|
logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (reminder.status !== "active") {
|
// Resumes are allowed even when the reminder's lifecycle status is
|
||||||
logger.info({ reminderId: reminder.id, status: reminder.status }, "fire-reminder: skipping (not active)");
|
// 'paused' — we WANT to take a paused reminder back to active mid-
|
||||||
|
// resume. Fresh fires still require status='active'.
|
||||||
|
if (!payload.runId && reminder.status !== "active") {
|
||||||
|
logger.info(
|
||||||
|
{ reminderId: reminder.id, status: reminder.status },
|
||||||
|
"fire-reminder: skipping (not active)",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-account mutex: two reminders on the SAME account take turns
|
// Per-account mutex: two reminders on the SAME account take turns
|
||||||
// (running them concurrently would double the effective send rate
|
// (running them concurrently would double the effective send rate
|
||||||
// and risk a ban). Different accounts run in parallel.
|
// and risk a ban). Different accounts run in parallel.
|
||||||
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder));
|
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder, payload.runId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fireReminderInner(
|
async function fireReminderInner(
|
||||||
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
|
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
|
||||||
|
resumeRunId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [run] = await db
|
// Resume path attaches to the existing run row; fresh path inserts a new one.
|
||||||
.insert(reminderRuns)
|
let runId: string;
|
||||||
.values({
|
if (resumeRunId) {
|
||||||
reminderId: reminder.id,
|
const existing = await db.query.reminderRuns.findFirst({
|
||||||
reminderName: reminder.name,
|
where: (r, { eq: dEq }) => dEq(r.id, resumeRunId),
|
||||||
status: "pending",
|
});
|
||||||
})
|
if (!existing) {
|
||||||
.returning({ id: reminderRuns.id });
|
logger.warn(
|
||||||
const runId = run!.id;
|
{ reminderId: reminder.id, resumeRunId },
|
||||||
|
"fire-reminder: resume target run missing",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runId = existing.id;
|
||||||
|
// Flip the run back to in-flight so the UI stops showing it as paused.
|
||||||
|
await db
|
||||||
|
.update(reminderRuns)
|
||||||
|
.set({ status: "pending", errorSummary: null })
|
||||||
|
.where(eq(reminderRuns.id, runId));
|
||||||
|
} else {
|
||||||
|
const [run] = await db
|
||||||
|
.insert(reminderRuns)
|
||||||
|
.values({
|
||||||
|
reminderId: reminder.id,
|
||||||
|
reminderName: reminder.name,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.returning({ id: reminderRuns.id });
|
||||||
|
runId = run!.id;
|
||||||
|
}
|
||||||
|
|
||||||
const session = sessionManager.getSession(reminder.accountId);
|
const session = sessionManager.getSession(reminder.accountId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected");
|
logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected");
|
||||||
await markAllSkipped(runId, reminder, "account not connected");
|
if (!resumeRunId) {
|
||||||
|
await markAllSkipped(runId, reminder, "account not connected");
|
||||||
|
}
|
||||||
await db
|
await db
|
||||||
.update(reminderRuns)
|
.update(reminderRuns)
|
||||||
.set({ status: "skipped", errorSummary: "account not connected" })
|
.set({ status: "skipped", errorSummary: "account not connected" })
|
||||||
.where(eq(reminderRuns.id, runId));
|
.where(eq(reminderRuns.id, runId));
|
||||||
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status: "skipped" });
|
await pgNotifyWeb({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: reminder.id,
|
||||||
|
runId,
|
||||||
|
status: "skipped",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,8 +161,9 @@ async function fireReminderInner(
|
|||||||
: [];
|
: [];
|
||||||
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
|
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
|
||||||
|
|
||||||
// Pre-create run_target rows so the Activity tab shows progress mid-run.
|
// Pre-create run_target rows on the fresh path so the Activity tab
|
||||||
if (reminder.targets.length > 0) {
|
// shows progress mid-run. Resume reuses the existing rows.
|
||||||
|
if (!resumeRunId && reminder.targets.length > 0) {
|
||||||
await db.insert(reminderRunTargets).values(
|
await db.insert(reminderRunTargets).values(
|
||||||
reminder.targets.map((t) => ({
|
reminder.targets.map((t) => ({
|
||||||
runId,
|
runId,
|
||||||
@ -127,11 +174,44 @@ async function fireReminderInner(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-run media upload cache. Each unique mediaId is prepared via
|
// On resume, only the still-pending rows are processed. On a fresh
|
||||||
// generateWAMessageContent ONCE (which uploads to WA's CDN through
|
// fire that's every row since we just inserted them all as pending.
|
||||||
// the socket's waUploadToServer); the resulting proto.Message is
|
const pendingRows = await db.query.reminderRunTargets.findMany({
|
||||||
// reused for every group via socket.relayMessage. For 1000 groups
|
where: (t, { eq: dEq, and: dAnd }) => dAnd(dEq(t.runId, runId), dEq(t.status, "pending")),
|
||||||
// × 5 MB image, this turns 5 GB of upload into 5 MB.
|
});
|
||||||
|
const pendingGroupIds = new Set(pendingRows.map((r) => r.groupId));
|
||||||
|
const targetsToProcess = reminder.targets.filter((t) => pendingGroupIds.has(t.groupId));
|
||||||
|
|
||||||
|
// Already-sent / already-failed counts from prior run rounds (resume
|
||||||
|
// case). The final tally adds these to what THIS round produces.
|
||||||
|
const priorSentCount = resumeRunId
|
||||||
|
? (
|
||||||
|
await db.query.reminderRunTargets.findMany({
|
||||||
|
where: (t, { eq: dEq, and: dAnd }) =>
|
||||||
|
dAnd(dEq(t.runId, runId), dEq(t.status, "sent")),
|
||||||
|
})
|
||||||
|
).length
|
||||||
|
: 0;
|
||||||
|
const priorFailedCount = resumeRunId
|
||||||
|
? (
|
||||||
|
await db.query.reminderRunTargets.findMany({
|
||||||
|
where: (t, { eq: dEq, and: dAnd }) =>
|
||||||
|
dAnd(dEq(t.runId, runId), dEq(t.status, "failed")),
|
||||||
|
})
|
||||||
|
).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Window-end timestamp. If the reminder fires AFTER today's deadline
|
||||||
|
// hour (cron miss-fired late, or it's already 7pm) this is in the
|
||||||
|
// past and the FIRST gate check trips immediately, ending the run
|
||||||
|
// as failed without sending anything.
|
||||||
|
const windowEnd = windowEndAt(
|
||||||
|
reminder.timezone,
|
||||||
|
reminder.deliveryWindowEndHour,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-run media upload cache (one prepare call per unique mediaId).
|
||||||
const uploadCache = new MediaUploadCache<proto.IMessage>(async (mediaId) => {
|
const uploadCache = new MediaUploadCache<proto.IMessage>(async (mediaId) => {
|
||||||
const media = mediaById.get(mediaId);
|
const media = mediaById.get(mediaId);
|
||||||
if (!media) throw new Error(`media row missing: ${mediaId}`);
|
if (!media) throw new Error(`media row missing: ${mediaId}`);
|
||||||
@ -157,19 +237,26 @@ async function fireReminderInner(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-account rate limiter — gates each socket send to stay within
|
// Per-account rate limiter — gates each socket send.
|
||||||
// the account's safe band (BOT_MAX_SEND_PER_MINUTE, default 40).
|
|
||||||
const rateLimiter = accountRateLimiter.get(reminder.accountId);
|
const rateLimiter = accountRateLimiter.get(reminder.accountId);
|
||||||
|
|
||||||
let sentCount = 0;
|
let sentCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
let skippedCount = 0;
|
let skippedCount = 0;
|
||||||
|
let windowClosed = false;
|
||||||
|
|
||||||
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
|
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
reminder.targets.map((target) =>
|
targetsToProcess.map((target) =>
|
||||||
groupConcurrency(async () => {
|
groupConcurrency(async () => {
|
||||||
|
// Window-end gate. CRITICAL: leave the row as `pending` (NOT
|
||||||
|
// `skipped`) so the run can be resumed later.
|
||||||
|
if (Date.now() >= windowEnd.getTime()) {
|
||||||
|
windowClosed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const group = groupById.get(target.groupId);
|
const group = groupById.get(target.groupId);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
await db
|
await db
|
||||||
@ -187,8 +274,6 @@ async function fireReminderInner(
|
|||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
// Once per group, before the first send. sendMessage handles
|
|
||||||
// sessions internally; relayMessage does not.
|
|
||||||
await ensureGroupSessions(session.socket, group.waGroupJid);
|
await ensureGroupSessions(session.socket, group.waGroupJid);
|
||||||
|
|
||||||
let lastMessageId: string | undefined;
|
let lastMessageId: string | undefined;
|
||||||
@ -242,14 +327,37 @@ async function fireReminderInner(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Compose the final status. Four shapes:
|
||||||
|
// paused : window closed mid-run with at least one row still pending
|
||||||
|
// AND we delivered at least one in this run or a prior round.
|
||||||
|
// Resumable. Sent rows stay sent, pending stays pending.
|
||||||
|
// success : every target sent.
|
||||||
|
// partial : every target attempted; some sent, some failed/skipped.
|
||||||
|
// failed : zero sent across all rounds, OR window closed before the
|
||||||
|
// first send (no progress to resume).
|
||||||
const total = reminder.targets.length;
|
const total = reminder.targets.length;
|
||||||
let status: "success" | "partial" | "failed";
|
const totalSent = priorSentCount + sentCount;
|
||||||
|
const totalFailed = priorFailedCount + failedCount;
|
||||||
|
const remainingPending = (
|
||||||
|
await db.query.reminderRunTargets.findMany({
|
||||||
|
where: (t, { eq: dEq, and: dAnd }) =>
|
||||||
|
dAnd(dEq(t.runId, runId), dEq(t.status, "pending")),
|
||||||
|
})
|
||||||
|
).length;
|
||||||
|
|
||||||
|
let status: "success" | "partial" | "failed" | "paused";
|
||||||
let errorSummary: string | null = null;
|
let errorSummary: string | null = null;
|
||||||
if (sentCount === total) {
|
if (windowClosed && remainingPending > 0 && totalSent > 0) {
|
||||||
|
status = "paused";
|
||||||
|
errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}). ${totalSent} of ${total} groups delivered, ${remainingPending} still pending. Resume from the Activity tab.`;
|
||||||
|
} else if (windowClosed && totalSent === 0) {
|
||||||
|
status = "failed";
|
||||||
|
errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}) before any group could be sent. The reminder fired too late in the day.`;
|
||||||
|
} else if (totalSent === total) {
|
||||||
status = "success";
|
status = "success";
|
||||||
} else if (sentCount > 0) {
|
} else if (totalSent > 0) {
|
||||||
status = "partial";
|
status = "partial";
|
||||||
errorSummary = `${sentCount} of ${total} groups delivered (${failedCount} failed, ${skippedCount} skipped).`;
|
errorSummary = `${totalSent} of ${total} groups delivered (${totalFailed} failed, ${skippedCount} skipped).`;
|
||||||
} else {
|
} else {
|
||||||
status = "failed";
|
status = "failed";
|
||||||
errorSummary = total === 0 ? "No targets attached to reminder." : `All ${total} sends failed.`;
|
errorSummary = total === 0 ? "No targets attached to reminder." : `All ${total} sends failed.`;
|
||||||
@ -260,29 +368,57 @@ async function fireReminderInner(
|
|||||||
.set({ status, errorSummary })
|
.set({ status, errorSummary })
|
||||||
.where(eq(reminderRuns.id, runId));
|
.where(eq(reminderRuns.id, runId));
|
||||||
|
|
||||||
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status });
|
await pgNotifyWeb({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: reminder.id,
|
||||||
|
runId,
|
||||||
|
status,
|
||||||
|
sent: totalSent,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
if (reminder.scheduleKind === "one_off") {
|
// Lifecycle bookkeeping. Skip when the run is paused — the reminder
|
||||||
|
// shouldn't end or re-arm while a resume is still possible. We also
|
||||||
|
// flip the reminder row itself to status='paused' so dashboards and
|
||||||
|
// the list view can reflect it.
|
||||||
|
if (status === "paused") {
|
||||||
await db
|
await db
|
||||||
.update(reminders)
|
.update(reminders)
|
||||||
.set({ status: "ended", updatedAt: new Date() })
|
.set({ status: "paused", updatedAt: new Date() })
|
||||||
.where(eq(reminders.id, reminder.id));
|
.where(eq(reminders.id, reminder.id));
|
||||||
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
logger.info(
|
||||||
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
|
{ reminderId: reminder.id, runId, totalSent, remainingPending },
|
||||||
await db
|
"fire-reminder: paused — leaving lifecycle alone for resume",
|
||||||
.update(reminders)
|
);
|
||||||
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
|
} else {
|
||||||
.where(eq(reminders.id, reminder.id));
|
if (reminder.scheduleKind === "one_off") {
|
||||||
if (next) {
|
await db
|
||||||
try {
|
.update(reminders)
|
||||||
await scheduleReminderFire(getBoss(), reminder.id, next);
|
.set({ status: "ended", updatedAt: new Date() })
|
||||||
logger.info({ reminderId: reminder.id, next }, "fire-reminder: re-armed for next occurrence");
|
.where(eq(reminders.id, reminder.id));
|
||||||
} catch (err) {
|
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
||||||
logger.error({ err, reminderId: reminder.id }, "fire-reminder: failed to re-arm next occurrence");
|
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
|
||||||
|
await db
|
||||||
|
.update(reminders)
|
||||||
|
.set({
|
||||||
|
// If we're resuming a previously-paused reminder, lift it
|
||||||
|
// back to active so the next cron occurrence fires normally.
|
||||||
|
status: "active",
|
||||||
|
lastFiredAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(reminders.id, reminder.id));
|
||||||
|
if (next) {
|
||||||
|
try {
|
||||||
|
await scheduleReminderFire(getBoss(), reminder.id, next);
|
||||||
|
logger.info({ reminderId: reminder.id, next }, "fire-reminder: re-armed for next occurrence");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, reminderId: reminder.id }, "fire-reminder: failed to re-arm next occurrence");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
|
||||||
|
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
|
|
||||||
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,7 +432,15 @@ async function fireReminderInner(
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ reminderId: reminder.id, runId, status, sent: sentCount, failed: failedCount, skipped: skippedCount },
|
{
|
||||||
|
reminderId: reminder.id,
|
||||||
|
runId,
|
||||||
|
status,
|
||||||
|
sent: sentCount,
|
||||||
|
failed: failedCount,
|
||||||
|
skipped: skippedCount,
|
||||||
|
windowClosed,
|
||||||
|
},
|
||||||
"fire-reminder: done",
|
"fire-reminder: done",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,13 @@ export type WebEventMap = {
|
|||||||
"session.disconnected": { accountId: string };
|
"session.disconnected": { accountId: string };
|
||||||
"session.timeout": { accountId: string };
|
"session.timeout": { accountId: string };
|
||||||
"groups.synced": { accountId: string; count: number };
|
"groups.synced": { accountId: string; count: number };
|
||||||
"reminder.fired": { reminderId: string; runId: string; status: string };
|
"reminder.fired": {
|
||||||
|
reminderId: string;
|
||||||
|
runId: string;
|
||||||
|
status: string;
|
||||||
|
sent?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
"reminder.failed": { reminderId: string; error: string };
|
"reminder.failed": { reminderId: string; error: string };
|
||||||
"send_test.done": { groupId: string; ok: boolean; error: string | null };
|
"send_test.done": { groupId: string; ok: boolean; error: string | null };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -240,6 +240,44 @@ describe("reminderFiredToNotification mapping", () => {
|
|||||||
expect(args).toBeNull();
|
expect(args).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders 'paused' with the resume/cancel call-to-action and sent/total", () => {
|
||||||
|
const args = reminderFiredToNotification({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: "r-p",
|
||||||
|
runId: "run-p",
|
||||||
|
status: "paused",
|
||||||
|
sent: 412,
|
||||||
|
total: 1000,
|
||||||
|
});
|
||||||
|
expect(args?.title).toBe("Reminder paused");
|
||||||
|
expect(args?.body).toBe("412 of 1000 groups delivered. Tap to resume or cancel.");
|
||||||
|
expect(args?.tag).toBe("reminder:r-p");
|
||||||
|
expect(args?.href).toBe("/reminders/r-p");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 'paused' without sent/total with a generic body", () => {
|
||||||
|
const args = reminderFiredToNotification({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: "r-p",
|
||||||
|
runId: "run-p",
|
||||||
|
status: "paused",
|
||||||
|
});
|
||||||
|
expect(args?.title).toBe("Reminder paused");
|
||||||
|
expect(args?.body).toMatch(/Delivery window closed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 'partial' with sent/total → 'X of Y groups delivered'", () => {
|
||||||
|
const args = reminderFiredToNotification({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: "r-2",
|
||||||
|
runId: "run-2",
|
||||||
|
status: "partial",
|
||||||
|
sent: 87,
|
||||||
|
total: 100,
|
||||||
|
});
|
||||||
|
expect(args?.body).toBe("87 of 100 groups delivered. See activity for details.");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the same tag for repeat fires of the same reminder so they coalesce", () => {
|
it("uses the same tag for repeat fires of the same reminder so they coalesce", () => {
|
||||||
const a = reminderFiredToNotification({
|
const a = reminderFiredToNotification({
|
||||||
type: "reminder.fired",
|
type: "reminder.fired",
|
||||||
|
|||||||
@ -138,20 +138,35 @@ export function reminderFiredToNotification(event: {
|
|||||||
reminderId: string;
|
reminderId: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
sent?: number;
|
||||||
|
total?: number;
|
||||||
}): ShowNotificationOptions | null {
|
}): ShowNotificationOptions | null {
|
||||||
if (event.status === "skipped") return null;
|
if (event.status === "skipped") return null;
|
||||||
const headline =
|
const headline =
|
||||||
event.status === "success"
|
event.status === "success"
|
||||||
? "Reminder sent"
|
? "Reminder sent"
|
||||||
: event.status === "partial"
|
: event.status === "paused"
|
||||||
? "Reminder partly sent"
|
? "Reminder paused"
|
||||||
: "Reminder failed";
|
: event.status === "partial"
|
||||||
const body =
|
? "Reminder partly sent"
|
||||||
|
: "Reminder failed";
|
||||||
|
let body =
|
||||||
event.status === "success"
|
event.status === "success"
|
||||||
? "All groups received the message."
|
? "All groups received the message."
|
||||||
: event.status === "partial"
|
: event.status === "paused"
|
||||||
? "Some groups received the message; others failed. See activity."
|
? "Delivery window closed before all groups got the message."
|
||||||
: "No groups received the message. See activity.";
|
: 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 {
|
return {
|
||||||
title: headline,
|
title: headline,
|
||||||
body,
|
body,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user