Compare commits

..

7 Commits

Author SHA1 Message Date
4cb4015666 fix(bot): dedupe duplicate reminder.fire jobs (msg sent twice)
Observed: reminder fired twice within ~2s. The bot logs showed two
distinct pg-boss jobIds for the same reminder enqueued at the same
scheduledAt — both ran fire-reminder, both sent the message.

Root cause: pg-boss's `singletonKey` only deduplicates on queues with
a 'singleton' / 'stately' / 'short' policy. Our queue was created
without specifying a policy, defaulting to 'standard', which IGNORES
the singletonKey. Two sends with the same key produced two jobs.

Fix lives at two layers:

* Layer 1 — queue policy. createQueue(REMINDER_FIRE_QUEUE) now
  passes `{ policy: 'stately' }`. With this, future fresh deploys
  fold a duplicate send (same singletonKey) into the existing
  'created' job rather than producing a second one. This doesn't
  retroactively change an existing queue's policy (pg-boss doesn't
  support that), but new queues are correct from creation.

* Layer 2 — defense-in-depth check inside fireReminder. Before
  acquiring the per-account mutex, query reminderRuns for any row
  with the same reminderId fired in the last 30s. If found, log
  + bail. This guards against:
    - Existing queues stuck on policy='standard'.
    - Race windows even within 'stately' policy.
    - The operator double-clicking Save in the wizard.
    - A jittery pg_notify('bot.command') replay.
  Resume jobs (payload.runId set) skip this check — they're meant
  to attach to an existing run.

Tests:
* New "BAILS OUT when a fresh fire collides with a recent run" case
  in fire-reminder.test.ts.
* beforeEach now resets findExistingRunMock too, since both the
  resume and dedupe paths share that mock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:41:11 +08:00
be3f28a1e6 refactor(web,bot,db): rename reminder status 'ended' → 'inactive'
The 'ended' label read like a terminal failure state ("the reminder
gave up") when in practice it just means "this reminder isn't going
to fire on its own — restart it if you want it back". 'inactive' is
the more accurate read.

* SQL migration 0009 backfills existing rows.
* Bot fire-reminder writes 'inactive' on one-off completion / no
  further occurrences.
* Web actions, queries, filters, and reminder lifecycle gates updated.
* Dashboard counter card label "Active / Paused / Ended / Total"
  becomes "Active / Paused / Inactive / Total".
* Reminders list filter tab "Ended" becomes "Inactive".
* Status pill style key renamed to match.
* Tests updated alongside the runtime changes.

Also: the "Pause sending by" deadline opt-in now renders as a
visible card-shaped row with hover state + Set/Off label on the
right, so the toggle is discoverable instead of a tiny native
checkbox tucked next to the label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:32:53 +08:00
2e1defaef6 feat(web): "Pause sending by" deadline is opt-in via a checkbox
Wizard When-step and the per-section Edit-when page now gate the
HourSelect behind a checkbox. The control reads "[ ] Pause sending
by (optional)" by default — checking it reveals the hour picker;
unchecking hides it again.

The off-state is encoded as deliveryWindowEndHour=24 (next-day
midnight) so the bot's existing windowEndAt helper produces an end
that's always in the future for any reminder fired the same day,
making the gate effectively never trip. This avoids a NULL-allowing
schema migration while still giving the operator a clean "no
deadline" mode.

Existing reminders:
  • Stored 24 → checkbox starts UNCHECKED, picker hidden.
  • Stored anything else → checkbox starts CHECKED, picker shows
    the saved value.
  • Unsupplied (legacy rows) → checkbox starts UNCHECKED.

RunEtaPill picks up an optional `windowEndAt` prop. When omitted —
the no-deadline path — it renders a neutral grey pill with just the
ETA, skipping the green "Fits before deadline" / amber "Likely to
pause" comparison that wouldn't be meaningful without a deadline.

Tests:
* when-form-deadline.test.tsx (4) — fresh / 24 / real-hour /
  optional-hint paths.
* run-eta-pill.test.tsx (+1) — neutral pill when windowEndAt is
  undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:25:44 +08:00
309020fa5d feat(bot): sweep stale 'pending' runs on startup
Corner case observed: fire-reminder writes the run row with
status='pending' UP FRONT (so the Activity tab shows progress
mid-run), then flips to a terminal status once it's done. If the
bot is killed between those two writes — e.g. a redeploy or crash —
the row sits at 'pending' forever. pg-boss already marked the job
'completed', so it won't retry. Activity surfaces and the dashboard
counters then show a "stuck" run that never moves.

sweepStalePendingRuns runs at bot startup, finds any 'pending' run
older than 5 minutes, and:
  • Flips the run to 'failed' with a clear error_summary so the UI
    stops treating it as in-flight.
  • Flips its still-'pending' run_target rows to 'skipped' with the
    same reason so per-group counts remain coherent.

The 5-minute floor is generous enough that an actual mid-run worker
rebalance isn't accidentally killed.

Tests:
* 4 sweep tests covering: no-stale path skips the second UPDATE;
  with-stale path fires both UPDATEs; counts are forwarded; the
  edge case where a stale run has zero pending targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:05:18 +08:00
bb8d28a594 feat(web): paused-run banner + Activity Paused filter / Resume button
Reminder detail page:

* Surfaces a PausedRunBanner above the rest of the surface when the
  most recent run is in 'paused' state. The banner shows the
  delivered/total counts, the deadline that closed the window, and
  Resume / Cancel run buttons that call the matching server actions.
* getReminderWithRuns now LEFT JOIN-aggregates run_target counts so
  the banner has sent/total per run without an N+1 fan-out.

Activity tab:

* New Paused filter tab between Success and Partial.
* Paused rows in the desktop table get an inline ResumeRunButton
  (emerald play icon, useTransition + error surfacing).
* RunStatusBadge picks up a Paused entry — amber, PauseCircle icon.

Tests:
* PausedRunBanner — 4 SSR cases (resume/cancel CTA rendered, X-of-Y
  copy, generic fallback, amber styling).
* ResumeRunButton — 4 SSR cases (aria, emerald accent, compact /
  default size variants).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:58:06 +08:00
376bbe595b feat(web,bot): resumeReminderRunAction + cancelReminderRunAction
Web actions:

* resumeReminderRunAction({ runId }) → validates ownership and that
  the run is in 'paused' state, then publishes a reminder.resume
  command via pg_notify('bot.command'). The bot's command-consumer
  picks it up and enqueues a fresh pg-boss job at REMINDER_FIRE_QUEUE
  carrying { reminderId, runId }; fire-reminder's existing resume
  branch attaches to the row.
* cancelReminderRunAction({ runId }) → flips remaining 'pending'
  targets to 'skipped' with error="canceled by operator", marks the
  run 'partial' with a clear errorSummary, and lifts the parent
  reminder out of 'paused' (recurring → active so the next
  occurrence fires; one-off → ended).

Bot:

* New BotCommand variant { type: "reminder.resume"; reminderId; runId }
* command-consumer registers handleResumeReminder which calls
  enqueueReminderResume(boss, reminderId, runId) — a sibling of
  scheduleReminderFire that posts the job at REMINDER_FIRE_QUEUE
  with { reminderId, runId } and singletonKey "reminder:resume:<runId>"
  so the resume doesn't conflict with a future-occurrence schedule.

Tests:
* reminders.run-actions.test.ts (11 tests) — every guard rail
  (invalid uuid, missing run, missing reminder, foreign operator,
  wrong status) and the recurring/one-off lifecycle branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:54:21 +08:00
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
38 changed files with 1514 additions and 132 deletions

View File

@ -9,6 +9,7 @@ import {
registerDefaultHandlers, registerDefaultHandlers,
} from "./ipc/command-consumer.js"; } from "./ipc/command-consumer.js";
import { sweepStalePendingAccounts } from "./ipc/pair-handler.js"; import { sweepStalePendingAccounts } from "./ipc/pair-handler.js";
import { sweepStalePendingRuns } from "./scheduler/sweep-stale-runs.js";
async function main(): Promise<void> { async function main(): Promise<void> {
logger.info("bot starting"); logger.info("bot starting");
@ -22,6 +23,7 @@ async function main(): Promise<void> {
const stopConsumer = await startCommandConsumer(); const stopConsumer = await startCommandConsumer();
await sweepStalePendingAccounts(); await sweepStalePendingAccounts();
await sweepStalePendingRuns();
await sessionManager.resumeFromDb(); await sessionManager.resumeFromDb();
const shutdown = async (signal: string): Promise<void> => { const shutdown = async (signal: string): Promise<void> => {

View File

@ -6,14 +6,18 @@ import { handleStartPairing } from "./pair-handler.js";
import { handleUnpair } from "./unpair-handler.js"; import { handleUnpair } from "./unpair-handler.js";
import { handleSyncGroups } from "./sync-groups-handler.js"; import { handleSyncGroups } from "./sync-groups-handler.js";
import { handleSendTest } from "./send-test-handler.js"; import { handleSendTest } from "./send-test-handler.js";
import { handleScheduleReminder } from "./schedule-reminder-handler.js"; import {
handleScheduleReminder,
handleResumeReminder,
} from "./schedule-reminder-handler.js";
export type BotCommand = export type BotCommand =
| { type: "account.start_pairing"; accountId: string } | { type: "account.start_pairing"; accountId: string }
| { type: "account.unpair"; accountId: string } | { type: "account.unpair"; accountId: string }
| { type: "account.sync_groups"; accountId: string } | { type: "account.sync_groups"; accountId: string }
| { type: "group.send_test"; groupId: string; text: string } | { type: "group.send_test"; groupId: string; text: string }
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }; | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
| { type: "reminder.resume"; reminderId: string; runId: string };
type Handler = (cmd: BotCommand) => Promise<void>; type Handler = (cmd: BotCommand) => Promise<void>;
const handlers: { [K in BotCommand["type"]]?: (cmd: Extract<BotCommand, { type: K }>) => Promise<void> } = {}; const handlers: { [K in BotCommand["type"]]?: (cmd: Extract<BotCommand, { type: K }>) => Promise<void> } = {};
@ -79,4 +83,7 @@ export function registerDefaultHandlers(): void {
registerHandler("reminder.schedule", async (cmd) => { registerHandler("reminder.schedule", async (cmd) => {
await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso); await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso);
}); });
registerHandler("reminder.resume", async (cmd) => {
await handleResumeReminder(cmd.reminderId, cmd.runId);
});
} }

View File

@ -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.

View File

@ -1,6 +1,16 @@
import { getBoss } from "../scheduler/pgboss-client.js"; import { getBoss } from "../scheduler/pgboss-client.js";
import { scheduleReminderFire } from "../scheduler/reminder-jobs.js"; import {
scheduleReminderFire,
enqueueReminderResume,
} from "../scheduler/reminder-jobs.js";
export async function handleScheduleReminder(reminderId: string, scheduledAtIso: string): Promise<void> { export async function handleScheduleReminder(reminderId: string, scheduledAtIso: string): Promise<void> {
await scheduleReminderFire(getBoss(), reminderId, new Date(scheduledAtIso)); await scheduleReminderFire(getBoss(), reminderId, new Date(scheduledAtIso));
} }
export async function handleResumeReminder(
reminderId: string,
runId: string,
): Promise<void> {
await enqueueReminderResume(getBoss(), reminderId, runId);
}

View File

@ -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) },
}, },
}, },
})); }));
@ -43,6 +54,7 @@ describe("fireReminder", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(accountMutex.run).mockClear(); vi.mocked(accountMutex.run).mockClear();
getReminderMock.mockReset(); getReminderMock.mockReset();
findExistingRunMock.mockReset();
}); });
it("acquires accountMutex keyed by accountId for active reminders", async () => { it("acquires accountMutex keyed by accountId for active reminders", async () => {
@ -56,6 +68,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",
}); });
@ -69,13 +83,15 @@ describe("fireReminder", () => {
getReminderMock.mockResolvedValue({ getReminderMock.mockResolvedValue({
id: "r-1", id: "r-1",
accountId: "acct-A", accountId: "acct-A",
status: "ended", status: "inactive",
targets: [], targets: [],
messages: [], messages: [],
createdBy: "op-1", createdBy: "op-1",
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 +108,66 @@ describe("fireReminder", () => {
expect(accountMutex.run).not.toHaveBeenCalled(); expect(accountMutex.run).not.toHaveBeenCalled();
}); });
it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => {
// Two pg-boss jobs landing within microseconds for the same
// reminder should NOT both fire. The first creates the run; the
// second sees that run is < DUPLICATE_FIRE_WINDOW_MS old and exits.
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "active",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test",
});
// The duplicate-fire check shares the reminderRuns.findFirst mock.
// Return a fresh run (firedAt = "just now") to simulate the
// collision.
findExistingRunMock.mockResolvedValue({
id: "run-recent",
reminderId: "r-1",
firedAt: new Date(),
status: "pending",
});
await fireReminder({ reminderId: "r-1" });
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 +179,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 +193,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",
}); });

View File

@ -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,23 @@ 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;
};
/**
* Window in which two fire-reminder jobs for the same reminder are
* treated as duplicates. Generous enough to absorb real-world double-
* submits (the operator clicks Save twice; pg_notify floods the
* command-consumer; pg-boss policy didn't dedupe a microsecond-apart
* race) short enough that a deliberately rapid recurring schedule
* (e.g. every minute, in dev) still fires every occurrence.
*/
const DUPLICATE_FIRE_WINDOW_MS = 30_000;
/** 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 +85,102 @@ 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;
} }
// Defense-in-depth dedupe: if pg-boss enqueues two reminder.fire jobs
// for the same reminderId within microseconds (e.g. a duplicate
// schedule call slipped past the queue's singletonKey), the second
// worker would otherwise create a SECOND run and the same message
// gets sent twice. Bail out if a run for this reminder already exists
// and was created less than DUPLICATE_FIRE_WINDOW_MS ago.
if (!payload.runId) {
const recent = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq, and: dAnd, gt: dGt }) =>
dAnd(
dEq(r.reminderId, reminder.id),
dGt(r.firedAt, new Date(Date.now() - DUPLICATE_FIRE_WINDOW_MS)),
),
orderBy: (r, { desc }) => [desc(r.firedAt)],
});
if (recent) {
logger.warn(
{
reminderId: reminder.id,
recentRunId: recent.id,
recentFiredAt: recent.firedAt,
},
"fire-reminder: duplicate fire detected (a run for this reminder was just created), skipping",
);
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 +199,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 +212,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 +275,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 +312,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 +365,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 +406,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: "inactive", 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: "inactive" }).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 +470,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",
); );
} }

View File

@ -6,7 +6,16 @@ import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
export const REMINDER_FIRE_QUEUE = "reminder.fire"; export const REMINDER_FIRE_QUEUE = "reminder.fire";
export async function registerReminderJobs(boss: PgBoss): Promise<void> { export async function registerReminderJobs(boss: PgBoss): Promise<void> {
await boss.createQueue(REMINDER_FIRE_QUEUE); // 'stately' = at most 1 job per (state, singletonKey). Combined with
// singletonKey="reminder:<id>" on every send, that means a duplicate
// schedule call (e.g. operator double-clicked Save, or the
// pg_notify('bot.command') consumer fired twice in the same tick)
// is folded into the existing 'created' job instead of producing a
// second run. The default 'standard' policy DOES NOT dedupe by
// singletonKey — that's how we ended up firing a reminder twice
// when two reminder.fire jobs landed within microseconds.
// https://github.com/timgit/pg-boss/blob/master/docs/usage.md#queue-policies
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "stately" });
await boss.work<FireReminderPayload>( await boss.work<FireReminderPayload>(
REMINDER_FIRE_QUEUE, REMINDER_FIRE_QUEUE,
{ {
@ -50,6 +59,31 @@ export async function scheduleReminderFire(
return id; return id;
} }
/**
* Re-enqueue a paused run so fire-reminder picks up the still-pending
* targets. Different singleton key from scheduleReminderFire so the
* resume doesn't clobber the next-occurrence scheduled job and vice
* versa.
*/
export async function enqueueReminderResume(
boss: PgBoss,
reminderId: string,
runId: string,
): Promise<string | null> {
const id = await boss.send(
REMINDER_FIRE_QUEUE,
{ reminderId, runId },
{
retryLimit: 3,
retryDelay: 30,
retryBackoff: true,
singletonKey: `reminder:resume:${runId}`,
},
);
logger.info({ reminderId, runId, jobId: id }, "reminder.fire: resume enqueued");
return id;
}
export async function cancelReminderFire(_boss: PgBoss, reminderId: string): Promise<void> { export async function cancelReminderFire(_boss: PgBoss, reminderId: string): Promise<void> {
// Soft cancel: pg-boss doesn't expose a clean cancel-by-singleton API in v12. // Soft cancel: pg-boss doesn't expose a clean cancel-by-singleton API in v12.
// The scheduled job will still fire, but `fireReminder` exits early when the // The scheduled job will still fire, but `fireReminder` exits early when the

View File

@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Corner case under test: fire-reminder writes the run row with
* status='pending' UP FRONT. If the bot is killed before it flips to
* a terminal status, the row sits at 'pending' indefinitely pg-boss
* won't retry (the job already ran). Activity surfaces, the dashboard
* counters, and the paused-banner all read the row at face value, so
* the operator sees a "stuck" run that never moves.
*
* sweepStalePendingRuns recovers from this on bot startup.
*/
// db.execute fan-out: build a list of {sql, return} pairs the test
// can assert on, and replay them in order. Ordering matters because
// the implementation does TWO updates (runs first, then targets) and
// the second one must only run if the first returned anything.
const executeMock = vi.fn();
vi.mock("../db.js", () => ({
db: {
execute: (...a: unknown[]) => executeMock(...a),
},
}));
import { sweepStalePendingRuns } from "./sweep-stale-runs.js";
beforeEach(() => {
executeMock.mockReset();
});
describe("sweepStalePendingRuns", () => {
it("returns 0 when no stale rows exist (skips the second UPDATE)", async () => {
executeMock.mockResolvedValueOnce({ rows: [] });
const r = await sweepStalePendingRuns();
expect(r).toEqual({ runs: 0, targets: 0 });
// Only the first UPDATE (runs) runs; no second UPDATE for targets.
expect(executeMock).toHaveBeenCalledTimes(1);
});
it("fires both UPDATEs when there ARE stale rows", async () => {
executeMock
.mockResolvedValueOnce({ rows: [{ id: "run-A" }, { id: "run-B" }] })
.mockResolvedValueOnce({ rows: [{ id: "t-1" }, { id: "t-2" }, { id: "t-3" }] });
const r = await sweepStalePendingRuns();
expect(r).toEqual({ runs: 2, targets: 3 });
expect(executeMock).toHaveBeenCalledTimes(2);
});
it("returns the actual swept counts so the caller can log them", async () => {
executeMock
.mockResolvedValueOnce({
rows: [{ id: "run-A" }, { id: "run-B" }, { id: "run-C" }],
})
.mockResolvedValueOnce({ rows: Array.from({ length: 17 }, (_, i) => ({ id: `t-${i}` })) });
const r = await sweepStalePendingRuns();
expect(r.runs).toBe(3);
expect(r.targets).toBe(17);
});
it("doesn't throw when the targets UPDATE returns no rows (run with no pending targets)", async () => {
// A stale run with zero pending targets is unusual but possible —
// the run row got the up-front insert but the per-target inserts
// never ran. Still a stale run, still gets cleared.
executeMock
.mockResolvedValueOnce({ rows: [{ id: "run-D" }] })
.mockResolvedValueOnce({ rows: [] });
const r = await sweepStalePendingRuns();
expect(r).toEqual({ runs: 1, targets: 0 });
});
});

View File

@ -0,0 +1,64 @@
import { sql } from "drizzle-orm";
import { db } from "../db.js";
import { logger } from "../logger.js";
/**
* Recover from "bot crashed / restarted mid-run" crashes.
*
* fire-reminder writes the run row with status='pending' UP FRONT so
* the Activity tab can show progress mid-run, then flips to a
* terminal status (success/partial/failed/paused/skipped) once it's
* done. If the bot dies between those two writes, the row sits at
* 'pending' forever pg-boss already marked the job 'completed', so
* it won't retry.
*
* This sweep runs at bot startup. It finds any 'pending' run older
* than `maxAgeMs` (default 5 minutes enough slack that a real
* mid-run rebalance to another worker isn't accidentally killed) and:
*
* Flips the run to 'failed' with a clear error_summary so the UI
* stops showing it as in-flight.
* Flips its pending run_target rows to 'skipped' with the same
* reason so per-group counts make sense.
*
* Does NOT touch the parent reminder's lifecycle status the row was
* 'active' when the run started and stays that way; the next
* occurrence (cron) or operator action will fire a fresh run.
*/
export async function sweepStalePendingRuns(
maxAgeMs: number = 5 * 60 * 1000,
): Promise<{ runs: number; targets: number }> {
const cutoffMs = Date.now() - maxAgeMs;
const cutoff = new Date(cutoffMs);
const runs = await db.execute(sql`
UPDATE reminder_runs
SET status = 'failed',
error_summary = 'Bot restarted before this run completed.'
WHERE status = 'pending'
AND fired_at < ${cutoff}
RETURNING id
`);
const runRows = runs.rows as Array<{ id: string }>;
if (runRows.length === 0) {
logger.info("sweep-stale-runs: no stale pending runs");
return { runs: 0, targets: 0 };
}
const ids = runRows.map((r) => r.id);
const targets = await db.execute(sql`
UPDATE reminder_run_targets
SET status = 'skipped',
error = 'bot restarted before this group could be sent'
WHERE status = 'pending'
AND run_id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})
RETURNING id
`);
const targetCount = (targets.rows as Array<unknown>).length;
logger.warn(
{ runs: runRows.length, targets: targetCount, cutoff: cutoff.toISOString() },
"sweep-stale-runs: cleared stale pending runs",
);
return { runs: runRows.length, targets: targetCount };
}

View File

@ -0,0 +1,211 @@
/**
* Unit-tests the resume + cancel server actions in isolation. We mock
* the seeded operator, drizzle db, and the pgNotifyBot helper so the
* tests exercise the action's auth / status / lifecycle logic without
* a real Postgres connection.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const findRunMock = vi.fn();
const findReminderMock = vi.fn();
const findAccountMock = vi.fn();
const updateMock = vi.fn();
const transactionMock = vi.fn();
const pgNotifyMock = vi.fn();
vi.mock("@/lib/db", () => ({
db: {
query: {
reminderRuns: { findFirst: (...a: unknown[]) => findRunMock(...a) },
reminders: { findFirst: (...a: unknown[]) => findReminderMock(...a) },
whatsappAccounts: {
findFirst: (...a: unknown[]) => findAccountMock(...a),
},
},
update: () => ({
set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }),
}),
// The cancel action does its DB mutations inside a transaction.
// Run the callback against the same shape as `db` so its inner
// `tx.update(...).set(...).where(...)` calls land in updateMock.
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
transactionMock();
const tx = {
update: () => ({
set: () => ({
where: async (...a: unknown[]) => updateMock(...a),
}),
}),
};
return fn(tx);
},
},
}));
vi.mock("@/lib/operator", () => ({
getSeededOperator: async () => ({ id: "op-1" }),
}));
vi.mock("@/lib/notify", () => ({
pgNotifyBot: (...a: unknown[]) => pgNotifyMock(...a),
}));
// Rate limiter doesn't fire from these actions, but stub it anyway in
// case the implementation grows it later.
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: async () => ({ limited: false }),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/headers", () => ({ headers: async () => new Map() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
import {
resumeReminderRunAction,
cancelReminderRunAction,
} from "./reminders";
const PAUSED_RUN = { id: "11111111-1111-1111-1111-111111111111", reminderId: "r-1", status: "paused" };
const REMINDER = { id: "r-1", accountId: "acc-1", scheduleKind: "recurring" };
const REMINDER_ONE_OFF = { ...REMINDER, scheduleKind: "one_off" };
const ACCOUNT = { id: "acc-1", operatorId: "op-1" };
beforeEach(() => {
findRunMock.mockReset();
findReminderMock.mockReset();
findAccountMock.mockReset();
updateMock.mockReset();
transactionMock.mockReset();
pgNotifyMock.mockReset();
});
describe("resumeReminderRunAction", () => {
it("rejects a non-uuid runId", async () => {
const r = await resumeReminderRunAction({ runId: "not-a-uuid" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/Invalid/);
});
it("returns 'Run not found' when the run row is missing", async () => {
findRunMock.mockResolvedValue(undefined);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: false, error: "Run not found" });
});
it("returns 'Reminder not found' when the run is orphaned", async () => {
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(undefined);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: false, error: "Reminder not found" });
});
it("returns 'Run not yours' when another operator owns the account", async () => {
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(undefined);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: false, error: "Run not yours" });
});
it("rejects when run.status !== 'paused'", async () => {
findRunMock.mockResolvedValue({ ...PAUSED_RUN, status: "success" });
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(ACCOUNT);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/Cannot resume a success run/);
});
it("happy path: notifies the bot with reminder.resume and runId", async () => {
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(ACCOUNT);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: true });
expect(pgNotifyMock).toHaveBeenCalledTimes(1);
expect(pgNotifyMock).toHaveBeenCalledWith({
type: "reminder.resume",
reminderId: REMINDER.id,
runId: PAUSED_RUN.id,
});
});
});
describe("cancelReminderRunAction", () => {
it("rejects a non-uuid runId", async () => {
const r = await cancelReminderRunAction({ runId: "nope" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/Invalid/);
});
it("rejects when the run isn't paused", async () => {
findRunMock.mockResolvedValue({ ...PAUSED_RUN, status: "success" });
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(ACCOUNT);
const r = await cancelReminderRunAction({ runId: PAUSED_RUN.id });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/Cannot cancel/);
});
it("happy path: opens a transaction and runs three updates (targets / run / reminder)", async () => {
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(ACCOUNT);
const r = await cancelReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: true });
expect(transactionMock).toHaveBeenCalledTimes(1);
// Three separate set/where calls inside the tx: update targets,
// update run, update reminder lifecycle.
expect(updateMock).toHaveBeenCalledTimes(3);
// Cancel does NOT enqueue the bot — it's purely a DB-side operation.
expect(pgNotifyMock).not.toHaveBeenCalled();
});
it("recurring reminder: lifecycle goes back to active so the next occurrence fires", async () => {
// Use a tx-update spy that captures the SET payload.
const setSpy = vi.fn();
const { db } = await import("@/lib/db");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(db as any).transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
update: () => ({
set: (payload: unknown) => {
setSpy(payload);
return { where: async () => undefined };
},
}),
};
return fn(tx);
};
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER); // recurring
findAccountMock.mockResolvedValue(ACCOUNT);
await cancelReminderRunAction({ runId: PAUSED_RUN.id });
// Last set call is on the reminders table — status flips to active.
const calls = setSpy.mock.calls;
const lastPayload = calls[calls.length - 1]?.[0] as Record<string, unknown>;
expect(lastPayload.status).toBe("active");
});
it("one-off reminder: lifecycle ends (no future occurrence to wait for)", async () => {
const setSpy = vi.fn();
const { db } = await import("@/lib/db");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(db as any).transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
update: () => ({
set: (payload: unknown) => {
setSpy(payload);
return { where: async () => undefined };
},
}),
};
return fn(tx);
};
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER_ONE_OFF);
findAccountMock.mockResolvedValue(ACCOUNT);
await cancelReminderRunAction({ runId: PAUSED_RUN.id });
const calls = setSpy.mock.calls;
const lastPayload = calls[calls.length - 1]?.[0] as Record<string, unknown>;
expect(lastPayload.status).toBe("inactive");
});
});

View File

@ -6,7 +6,13 @@ import { headers } from "next/headers";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db"; import {
reminders,
reminderTargets,
reminderMessages,
reminderRuns,
reminderRunTargets,
} from "@cmbot/db";
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared"; import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
@ -538,3 +544,129 @@ export async function updateReminderAction(
revalidatePath(`/reminders/${reminderId}`); revalidatePath(`/reminders/${reminderId}`);
return { ok: true, reminderId }; return { ok: true, reminderId };
} }
// ---------------------------------------------------------------------------
// Resume / cancel a paused run
// ---------------------------------------------------------------------------
const runIdSchema = z.object({ runId: z.string().uuid() });
export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string };
/**
* Re-enqueue a paused reminder run. The bot picks it up, attaches to the
* existing run row, and only re-tries the rows still in `pending` state.
*
* Validates that the operator owns the underlying reminder + account
* pair and that the run is actually in 'paused' state anything else
* is a no-op (so a stale UI button doesn't double-fire a run).
*/
export async function resumeReminderRunAction(input: {
runId: string;
}): Promise<ResumeReminderRunResult> {
const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: "Invalid runId" };
}
const run = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq }) => dEq(r.id, parsed.data.runId),
});
if (!run || !run.reminderId) return { ok: false, error: "Run not found" };
const reminder = await db.query.reminders.findFirst({
where: (r, { eq: dEq }) => dEq(r.id, run.reminderId!),
});
if (!reminder) return { ok: false, error: "Reminder not found" };
// Operator must own the account the reminder belongs to.
const owned = await db.query.whatsappAccounts.findFirst({
where: (a, { eq: dEq, and: dAnd }) =>
dAnd(dEq(a.id, reminder.accountId), dEq(a.operatorId, op.id)),
});
if (!owned) return { ok: false, error: "Run not yours" };
if (run.status !== "paused") {
return { ok: false, error: `Cannot resume a ${run.status} run` };
}
await pgNotifyBot({
type: "reminder.resume",
reminderId: reminder.id,
runId: run.id,
});
revalidatePath("/activity");
revalidatePath(`/reminders/${reminder.id}`);
return { ok: true };
}
export type CancelReminderRunResult = { ok: true } | { ok: false; error: string };
/**
* Permanently end a paused run. Remaining `pending` targets become
* `skipped` with a clear "canceled by operator" reason; the run row
* resolves to `partial`. The reminder lifecycle is lifted out of
* 'paused' recurring goes back to 'active' so the next occurrence
* fires; one-off ends.
*/
export async function cancelReminderRunAction(input: {
runId: string;
}): Promise<CancelReminderRunResult> {
const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: "Invalid runId" };
}
const run = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq }) => dEq(r.id, parsed.data.runId),
});
if (!run || !run.reminderId) return { ok: false, error: "Run not found" };
const reminder = await db.query.reminders.findFirst({
where: (r, { eq: dEq }) => dEq(r.id, run.reminderId!),
});
if (!reminder) return { ok: false, error: "Reminder not found" };
const owned = await db.query.whatsappAccounts.findFirst({
where: (a, { eq: dEq, and: dAnd }) =>
dAnd(dEq(a.id, reminder.accountId), dEq(a.operatorId, op.id)),
});
if (!owned) return { ok: false, error: "Run not yours" };
if (run.status !== "paused") {
return { ok: false, error: `Cannot cancel a ${run.status} run` };
}
await db.transaction(async (tx) => {
// Pending → skipped with a clear cause.
await tx
.update(reminderRunTargets)
.set({ status: "skipped", error: "canceled by operator" })
.where(eq(reminderRunTargets.runId, run.id));
await tx
.update(reminderRuns)
.set({
status: "partial",
errorSummary:
"Canceled by operator before all groups received the message.",
})
.where(eq(reminderRuns.id, run.id));
// Lift the reminder out of 'paused'. Recurring goes back to active
// so the next occurrence can fire; one-off has no future occurrence.
await tx
.update(reminders)
.set({
status: reminder.scheduleKind === "recurring" ? "active" : "inactive",
updatedAt: new Date(),
})
.where(eq(reminders.id, reminder.id));
});
revalidatePath("/activity");
revalidatePath(`/reminders/${reminder.id}`);
return { ok: true };
}

View File

@ -6,6 +6,8 @@ import {
ArchiveRestoreIcon, ArchiveRestoreIcon,
CheckCircle2Icon, CheckCircle2Icon,
MinusCircleIcon, MinusCircleIcon,
PauseCircleIcon,
PlayIcon,
Trash2Icon, Trash2Icon,
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
@ -41,6 +43,7 @@ import {
unarchiveRunAction, unarchiveRunAction,
} from "@/actions/history"; } from "@/actions/history";
import { SwipeableRow } from "@/components/swipeable-row"; import { SwipeableRow } from "@/components/swipeable-row";
import { ResumeRunButton } from "@/components/activity/resume-run-button";
function relativeTime(date: Date | string): string { function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
@ -62,6 +65,12 @@ const RUN_STATUS_CONFIG: Record<
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
icon: CheckCircle2Icon, icon: CheckCircle2Icon,
}, },
paused: {
label: "Paused",
className:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
icon: PauseCircleIcon,
},
partial: { partial: {
label: "Partial", label: "Partial",
className: className:
@ -97,10 +106,18 @@ function RunStatusBadge({ status }: { status: string }) {
); );
} }
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped" | "archived"; type FilterValue =
| "all"
| "success"
| "paused"
| "partial"
| "failed"
| "skipped"
| "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
{ value: "success", label: "Success" }, { value: "success", label: "Success" },
{ value: "paused", label: "Paused" },
{ value: "partial", label: "Partial" }, { value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" }, { value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" }, { value: "skipped", label: "Skipped" },
@ -167,6 +184,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const sp = await searchParams; const sp = await searchParams;
const filter: FilterValue = const filter: FilterValue =
sp.filter === "success" || sp.filter === "success" ||
sp.filter === "paused" ||
sp.filter === "partial" || sp.filter === "partial" ||
sp.filter === "failed" || sp.filter === "failed" ||
sp.filter === "skipped" || sp.filter === "skipped" ||
@ -354,6 +372,9 @@ export default async function ActivityPage({ searchParams }: PageProps) {
</TableCell> </TableCell>
<TableCell className="text-right pr-2 whitespace-nowrap"> <TableCell className="text-right pr-2 whitespace-nowrap">
<div className="inline-flex items-center gap-0.5"> <div className="inline-flex items-center gap-0.5">
{run.status === "paused" && (
<ResumeRunButton runId={run.id} />
)}
<form <form
action={ action={
isArchived ? unarchiveRunAction : archiveRunAction isArchived ? unarchiveRunAction : archiveRunAction

View File

@ -182,9 +182,9 @@ export default async function DashboardPage() {
/> />
<StatCard <StatCard
title="Reminders" title="Reminders"
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.endedReminders} / ${stats.totalReminders}`} value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.inactiveReminders} / ${stats.totalReminders}`}
icon={BellIcon} icon={BellIcon}
description="Active / Paused / Ended / Total" description="Active / Paused / Inactive / Total"
href="/reminders" href="/reminders"
/> />
</div> </div>

View File

@ -41,9 +41,9 @@ describe("ActionsBar — card visibility by status", () => {
expect(html).not.toMatch(/aria-label="Pause"/); expect(html).not.toMatch(/aria-label="Pause"/);
}); });
it("ended: shows Restart and Delete (no Pause)", () => { it("inactive: shows Restart and Delete (no Pause)", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="ended" isRecurring={false} />, <ActionsBar reminderId="r-1" status="inactive" isRecurring={false} />,
); );
expect(html).toMatch(/aria-label="Restart"/); expect(html).toMatch(/aria-label="Restart"/);
expect(html).toMatch(/aria-label="Delete"/); expect(html).toMatch(/aria-label="Delete"/);

View File

@ -38,7 +38,7 @@ interface ActionsBarProps {
* on desktop, stacked on mobile: * on desktop, stacked on mobile:
* *
* - Pause only when status === "active" * - Pause only when status === "active"
* - Restart when status is "paused" or "ended" * - Restart when status is "paused" or "inactive"
* - Delete always available (terminal) * - Delete always available (terminal)
* *
* Each Dialog confirms before firing the corresponding server action. * Each Dialog confirms before firing the corresponding server action.
@ -46,7 +46,7 @@ interface ActionsBarProps {
*/ */
export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) { export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) {
const canPause = status === "active"; const canPause = status === "active";
const canRestart = status === "paused" || status === "ended"; const canRestart = status === "paused" || status === "inactive";
return ( return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">

View File

@ -30,6 +30,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries"; import { getReminderWithRuns } from "@/lib/queries";
import { PausedRunBanner } from "@/components/reminder-detail/paused-run-banner";
import { ActionsBar } from "./actions-bar"; import { ActionsBar } from "./actions-bar";
function formatWhen(date: Date | null, tz: string): string { function formatWhen(date: Date | null, tz: string): string {
@ -47,7 +48,7 @@ function formatWhen(date: Date | null, tz: string): string {
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
active: active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
ended: inactive:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent", "bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused: paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent", "bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
@ -119,6 +120,22 @@ export default async function ReminderDetailPage({ params }: Props) {
</p> </p>
</div> </div>
{/* Most recent paused run gets a banner — Resume / Cancel are
one click away. Pause notifications deep-link here. */}
{(() => {
const pausedRun = runs.find((r) => r.status === "paused");
if (!pausedRun) return null;
return (
<PausedRunBanner
runId={pausedRun.id}
sent={pausedRun.sent}
total={pausedRun.total}
windowEndHour={reminder.deliveryWindowEndHour}
timezone={reminder.timezone}
/>
);
})()}
<Separator /> <Separator />
{/* Name click to edit. Required field, the operator's {/* Name click to edit. Required field, the operator's

View File

@ -32,7 +32,7 @@ import {
restartReminderAction, restartReminderAction,
} from "@/actions/reminders"; } from "@/actions/reminders";
type FilterValue = "all" | "active" | "ended" | "paused"; type FilterValue = "all" | "active" | "inactive" | "paused";
function formatWhen(date: Date | null, tz: string): string { function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—"; if (!date) return "—";
@ -48,7 +48,7 @@ function formatWhen(date: Date | null, tz: string): string {
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
active: active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
ended: inactive:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent", "bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused: paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent", "bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
@ -104,7 +104,7 @@ function StatusPill({ status }: { status: string }) {
const FILTER_TABS: { value: FilterValue; label: string }[] = [ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
{ value: "active", label: "Active" }, { value: "active", label: "Active" },
{ value: "ended", label: "Ended" }, { value: "inactive", label: "Inactive" },
{ value: "paused", label: "Paused" }, { value: "paused", label: "Paused" },
]; ];
@ -127,7 +127,7 @@ interface PageProps {
export default async function RemindersPage({ searchParams }: PageProps) { export default async function RemindersPage({ searchParams }: PageProps) {
const sp = await searchParams; const sp = await searchParams;
const status: FilterValue = const status: FilterValue =
sp.filter === "active" || sp.filter === "ended" || sp.filter === "paused" sp.filter === "active" || sp.filter === "inactive" || sp.filter === "paused"
? sp.filter ? sp.filter
: "all"; : "all";
// Sort is now fixed to `created_desc`. Reordering on every status flip // Sort is now fixed to `created_desc`. Reordering on every status flip
@ -225,7 +225,7 @@ export default async function RemindersPage({ searchParams }: PageProps) {
{visible.map((reminder) => { {visible.map((reminder) => {
const canPause = reminder.status === "active"; const canPause = reminder.status === "active";
const canRestart = const canRestart =
reminder.status === "paused" || reminder.status === "ended"; reminder.status === "paused" || reminder.status === "inactive";
const cardBody = ( const cardBody = (
<Link <Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -0,0 +1,32 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/actions/reminders", () => ({
resumeReminderRunAction: vi.fn(),
}));
import { ResumeRunButton } from "./resume-run-button";
describe("ResumeRunButton", () => {
it("renders an icon button with aria-label='Resume run'", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" />);
expect(html).toMatch(/aria-label="Resume run"/);
expect(html).toMatch(/lucide-play/);
});
it("uses emerald accent so paused rows clearly offer 'go again'", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" />);
expect(html).toMatch(/text-emerald-700/);
});
it("compact variant uses size=icon-sm so it fits inline in the table", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" variant="compact" />);
// shadcn button forwards size into a data-size attr.
expect(html).toMatch(/data-size="icon-sm"/);
});
it("default variant uses size=sm for a standalone surface", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" variant="default" />);
expect(html).toMatch(/data-size="sm"/);
});
});

View File

@ -0,0 +1,54 @@
"use client";
import { useTransition, useState } from "react";
import { Loader2Icon, PlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { resumeReminderRunAction } from "@/actions/reminders";
interface ResumeRunButtonProps {
runId: string;
/** Style hint "compact" suits inline rows, "default" suits the
* paused-detail banner which renders its own size already. */
variant?: "compact" | "default";
}
/**
* Small wrapper around resumeReminderRunAction so paused rows in the
* Activity tab can offer "Resume" without each row rolling its own
* useTransition / error handling. Cancel uses the detail banner
* it's the rarer path.
*/
export function ResumeRunButton({ runId, variant = "compact" }: ResumeRunButtonProps) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
const onClick = () =>
start(async () => {
setError(null);
const r = await resumeReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
return (
<div className="inline-flex flex-col items-end gap-0.5">
<Button
type="button"
size={variant === "compact" ? "icon-sm" : "sm"}
variant="ghost"
onClick={onClick}
disabled={pending}
aria-label="Resume run"
className="text-emerald-700 hover:text-emerald-800 dark:text-emerald-400 dark:hover:text-emerald-300"
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PlayIcon className="size-4" />
)}
</Button>
{error && (
<span className="text-[10px] text-destructive whitespace-nowrap">{error}</span>
)}
</div>
);
}

View File

@ -0,0 +1,71 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/actions/reminders", () => ({
resumeReminderRunAction: vi.fn(),
cancelReminderRunAction: vi.fn(),
}));
import { PausedRunBanner } from "./paused-run-banner";
describe("PausedRunBanner — SSR layout", () => {
it("renders Resume + Cancel buttons inside the banner", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
sent={412}
total={1000}
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toContain('data-testid="paused-run-banner"');
expect(html).toContain('data-testid="paused-resume"');
expect(html).toContain('data-testid="paused-cancel"');
expect(html).toMatch(/Resume<\/button>/);
expect(html).toMatch(/Cancel run<\/button>/);
});
it("shows X of Y groups delivered when sent + total are present", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
sent={412}
total={1000}
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toContain("412 of 1000 groups delivered");
// Surfaces the window-end deadline so the operator knows why.
expect(html).toContain("18:00 (Asia/Kuala_Lumpur)");
// And the remaining count drives the CTA copy.
expect(html).toContain("send the remaining 588");
});
it("falls back to a generic body when sent / total aren't supplied", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toMatch(/delivery window closed before/i);
expect(html).not.toContain("groups delivered");
});
it("uses amber styling so the banner reads as 'attention, not error'", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
sent={1}
total={2}
windowEndHour={18}
timezone="UTC"
/>,
);
expect(html).toMatch(/border-amber-500/);
expect(html).toMatch(/bg-amber-500/);
});
});

View File

@ -0,0 +1,118 @@
"use client";
import { useState, useTransition } from "react";
import {
AlertCircleIcon,
PlayIcon,
XIcon,
Loader2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
resumeReminderRunAction,
cancelReminderRunAction,
} from "@/actions/reminders";
interface PausedRunBannerProps {
runId: string;
/** Best-effort sent count for the body copy. Falls back to a
* generic message when undefined. */
sent?: number;
/** Best-effort total target count. */
total?: number;
/** Deadline hour the bot stopped at. Shown in the body copy. */
windowEndHour: number;
/** Operator timezone (for the deadline label). */
timezone: string;
}
/**
* Amber callout shown above the reminder detail view when the most
* recent run is in 'paused' state. Two interactive choices:
* Resume re-enqueues the run via the bot.
* Cancel run stops the run cleanly (remaining pending skipped).
*
* Pause notifications deep-link the operator into this surface.
*/
export function PausedRunBanner({
runId,
sent,
total,
windowEndHour,
timezone,
}: PausedRunBannerProps) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const onResume = () =>
startTransition(async () => {
setError(null);
const r = await resumeReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
const onCancel = () =>
startTransition(async () => {
setError(null);
const r = await cancelReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
const remaining =
typeof sent === "number" && typeof total === "number"
? Math.max(0, total - sent)
: null;
return (
<div
className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-4 space-y-3"
data-testid="paused-run-banner"
>
<div className="flex items-start gap-2">
<AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="space-y-1 text-sm">
<p className="font-medium">Reminder paused</p>
<p className="text-xs text-muted-foreground">
{typeof sent === "number" && typeof total === "number"
? `${sent} of ${total} groups delivered.`
: "The delivery window closed before all groups got the message."}{" "}
The deadline was {windowEndHour}:00 ({timezone}).{" "}
{remaining !== null && remaining > 0
? `Resume to send the remaining ${remaining}, or cancel the run.`
: "Resume to keep going, or cancel the run."}
</p>
</div>
</div>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
<div className="flex gap-2">
<Button
size="sm"
onClick={onResume}
disabled={pending}
className="gap-2"
data-testid="paused-resume"
>
{pending ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<PlayIcon className="size-3.5" />
)}
Resume
</Button>
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={pending}
className="gap-2"
data-testid="paused-cancel"
>
<XIcon className="size-3.5" />
Cancel run
</Button>
</div>
</div>
);
}

View File

@ -52,7 +52,15 @@ export function EditWhenForm({
const [date, setDate] = useState(initial.date); const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time); const [time, setTime] = useState(initial.time);
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec); const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(initialDeliveryEndHour); // Optional deadline: 24 (next-day midnight) is the off-sentinel —
// hour=24 makes windowEndAt return tomorrow's start, effectively
// "no deadline today". Existing rows at 24 land with the toggle
// OFF; rows at any other value land toggled ON with that value.
const initialUseDeadline = initialDeliveryEndHour !== 24;
const [useDeadline, setUseDeadline] = useState<boolean>(initialUseDeadline);
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
initialUseDeadline ? initialDeliveryEndHour : 18,
);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -108,7 +116,7 @@ export function EditWhenForm({
scheduledAtIso, scheduledAtIso,
rrule, rrule,
timezone, timezone,
deliveryWindowEndHour: deliveryEndHour, deliveryWindowEndHour: useDeadline ? deliveryEndHour : 24,
}); });
if (r.ok) { if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -162,23 +170,40 @@ export function EditWhenForm({
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} /> <RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
<div className="space-y-1.5"> <div className="space-y-2">
<Label className="flex items-center gap-1.5"> <label className="flex items-center gap-3 rounded-lg border border-input bg-card px-3 py-2.5 cursor-pointer select-none transition-colors hover:bg-accent/40">
<ClockIcon className="size-3.5" /> <input
Pause sending by type="checkbox"
<span className="text-xs font-normal text-muted-foreground">(optional)</span> checked={useDeadline}
</Label> onChange={(e) => {
<div className="flex flex-wrap items-center gap-2"> setUseDeadline(e.target.checked);
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null); setError(null);
}} }}
className="size-5 rounded border-input accent-primary"
aria-label="Set a delivery deadline"
/> />
<span className="text-xs text-muted-foreground">({timezone})</span> <span className="flex-1 flex items-center gap-1.5 text-sm font-medium">
</div> <ClockIcon className="size-3.5" />
Pause sending by
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
</span>
<span className="text-xs text-muted-foreground">
{useDeadline ? "Set" : "Off"}
</span>
</label>
{useDeadline && (
<div className="flex flex-wrap items-center gap-2 pl-3">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null);
}}
/>
<span className="text-xs text-muted-foreground">({timezone})</span>
</div>
)}
</div> </div>
{error && ( {error && (

View File

@ -82,8 +82,12 @@ export function ReviewSubmitClient({
const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0; const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0;
const fireAt = new Date(scheduledAt); const fireAt = new Date(scheduledAt);
const endHour = deliveryEndHour ?? 18; // Treat hour=24 (or unset) as "no deadline". The pill goes neutral.
const wEnd = windowEndAt(timezone, endHour, fireAt); const hasDeadline =
deliveryEndHour !== undefined && deliveryEndHour !== 24;
const wEnd = hasDeadline
? windowEndAt(timezone, deliveryEndHour!, fireAt)
: undefined;
return ( return (
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">

View File

@ -3,6 +3,20 @@ import { renderToStaticMarkup } from "react-dom/server";
import { RunEtaPill } from "./run-eta-pill"; import { RunEtaPill } from "./run-eta-pill";
describe("RunEtaPill", () => { describe("RunEtaPill", () => {
it("renders a neutral ETA when windowEndAt is omitted (no deadline)", () => {
const html = renderToStaticMarkup(
<RunEtaPill
targetCount={500}
fireAt={new Date("2026-05-13T09:00:00.000+08:00")}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toContain('data-testid="eta-pill-neutral"');
expect(html).toMatch(/min/);
expect(html).not.toMatch(/Fits before deadline/);
expect(html).not.toMatch(/Likely to pause/);
});
it("renders nothing for zero targets", () => { it("renders nothing for zero targets", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<RunEtaPill <RunEtaPill

View File

@ -4,7 +4,10 @@ import { estimateRunDuration } from "@/lib/run-eta";
interface RunEtaPillProps { interface RunEtaPillProps {
targetCount: number; targetCount: number;
fireAt: Date; fireAt: Date;
windowEndAt: Date; /** Optional. When omitted (or when the operator picked "no
* deadline"), the pill renders a neutral ETA without the
* green/amber fit indicator. */
windowEndAt?: Date;
timezone: string; timezone: string;
} }
@ -27,8 +30,6 @@ export function RunEtaPill({
targetCount, targetCount,
fireAt, fireAt,
}); });
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
const finishLocal = new Intl.DateTimeFormat("en-GB", { const finishLocal = new Intl.DateTimeFormat("en-GB", {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
@ -36,6 +37,23 @@ export function RunEtaPill({
timeZone: timezone, timeZone: timezone,
}).format(estimatedFinishAt); }).format(estimatedFinishAt);
// No deadline → neutral ETA, no green/amber comparison.
if (!windowEndAt) {
return (
<div
className="flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground"
data-testid="eta-pill-neutral"
>
<ClockIcon className="size-3.5" />
<span>
~{durationMinutes} min · finishes ~{finishLocal}
</span>
</div>
);
}
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
if (fits) { if (fits) {
return ( return (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400"> <div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">

View File

@ -45,8 +45,16 @@ export function WhenFormClient({
const [date, setDate] = useState(initial.date); const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time); const [time, setTime] = useState(initial.time);
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE); const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE);
// Deadline is optional. We model it as two states: a checkbox that
// turns it on/off, and the picked hour (only meaningful when the
// checkbox is on). 24 (next-day midnight) is the off-sentinel sent
// to the server — windowEndAt treats it as "end of today" so the
// bot's window-end gate effectively never trips for short runs.
const initialUseDeadline =
initialDeliveryEndHour !== undefined && initialDeliveryEndHour !== 24;
const [useDeadline, setUseDeadline] = useState<boolean>(initialUseDeadline);
const [deliveryEndHour, setDeliveryEndHour] = useState<number>( const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
initialDeliveryEndHour ?? 18, initialUseDeadline ? (initialDeliveryEndHour ?? 18) : 18,
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -77,7 +85,8 @@ export function WhenFormClient({
if (passThroughParams.name) sp.set("name", passThroughParams.name); if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
sp.set("deliveryEndHour", String(deliveryEndHour)); // 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
return; return;
@ -121,7 +130,8 @@ export function WhenFormClient({
if (passThroughParams.name) sp.set("name", passThroughParams.name); if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
sp.set("deliveryEndHour", String(deliveryEndHour)); // 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
} }
@ -168,24 +178,43 @@ export function WhenFormClient({
{/* Deadline fire time is the implicit start; this only sets when {/* Deadline fire time is the implicit start; this only sets when
the bot must stop. Long fan-outs that don't finish before the the bot must stop. Long fan-outs that don't finish before the
deadline are paused so the operator can resume them later. */} deadline are paused so the operator can resume them later.
<div className="space-y-1.5"> The whole control is opt-in: tick the box to surface the hour
<Label className="flex items-center gap-1.5"> picker, untick to remove the deadline entirely. */}
<ClockIcon className="size-3.5" /> <div className="space-y-2">
Pause sending by <label className="flex items-center gap-3 rounded-lg border border-input bg-card px-3 py-2.5 cursor-pointer select-none transition-colors hover:bg-accent/40">
<span className="text-xs font-normal text-muted-foreground">(optional)</span> <input
</Label> type="checkbox"
<div className="flex flex-wrap items-center gap-2"> checked={useDeadline}
<HourSelect onChange={(e) => {
ariaPrefix="Delivery deadline" setUseDeadline(e.target.checked);
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null); setError(null);
}} }}
className="size-5 rounded border-input accent-primary"
aria-label="Set a delivery deadline"
/> />
<span className="text-xs text-muted-foreground">({timezone})</span> <span className="flex-1 flex items-center gap-1.5 text-sm font-medium">
</div> <ClockIcon className="size-3.5" />
Pause sending by
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
</span>
<span className="text-xs text-muted-foreground">
{useDeadline ? "Set" : "Off"}
</span>
</label>
{useDeadline && (
<div className="flex flex-wrap items-center gap-2 pl-3">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null);
}}
/>
<span className="text-xs text-muted-foreground">({timezone})</span>
</div>
)}
</div> </div>
{error && ( {error && (

View File

@ -0,0 +1,86 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
// next/navigation is touched by useRouter — stub it.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
// next/link → transparent <a> so the markup we assert on stays simple.
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: { href: string; children: ReactNode } & Record<string, unknown>) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
import { WhenFormClient } from "./when-form-client";
const baseProps = {
accountId: "acc-1",
groupIds: "g-1",
timezone: "Asia/Kuala_Lumpur",
initialDefaultIso: "2026-05-13T09:00:00.000+08:00",
passThroughParams: { name: "test", messages: "x" },
};
/**
* The "Pause sending by" deadline is opt-in. The checkbox controls
* whether the HourSelect is rendered at all; when off, the form
* sends 24 (next-day midnight) to the server, which makes the bot's
* window-end gate effectively never trip. These tests lock in the
* SSR markup for the three states (off by default, off when the
* stored value is 24, on when the stored value is something else).
*/
describe("WhenFormClient — deadline checkbox", () => {
it("defaults to UNCHECKED for a fresh reminder (no initialDeliveryEndHour)", () => {
const html = renderToStaticMarkup(<WhenFormClient {...baseProps} />);
// Checkbox is rendered but not checked.
expect(html).toMatch(
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*>/,
);
expect(html).not.toMatch(
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
);
// No HourSelect rendered while the box is unchecked.
expect(html).not.toMatch(/aria-label="Delivery deadline hour"/);
});
it("starts UNCHECKED when initialDeliveryEndHour is 24 (the off-sentinel)", () => {
const html = renderToStaticMarkup(
<WhenFormClient {...baseProps} initialDeliveryEndHour={24} />,
);
expect(html).not.toMatch(
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
);
expect(html).not.toMatch(/aria-label="Delivery deadline hour"/);
});
it("starts CHECKED + reveals the hour picker when initialDeliveryEndHour is set to a real hour", () => {
const html = renderToStaticMarkup(
<WhenFormClient {...baseProps} initialDeliveryEndHour={18} />,
);
// Checkbox is checked.
expect(html).toMatch(
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
);
// The hour + period selects render under the checkbox.
expect(html).toMatch(/aria-label="Delivery deadline hour"/);
expect(html).toMatch(/aria-label="Delivery deadline period"/);
// Pre-selected hour matches the initial value (18 → 6 PM).
expect(html).toMatch(/value="6"\s+selected/);
expect(html).toMatch(/value="PM"\s+selected/);
});
it("offers a clear (optional) hint next to the label", () => {
const html = renderToStaticMarkup(<WhenFormClient {...baseProps} />);
expect(html).toContain("Pause sending by");
expect(html).toContain("(optional)");
});
});

View File

@ -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 };
}; };

View File

@ -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",

View File

@ -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,

View File

@ -7,7 +7,8 @@ export type BotCommand =
| { type: "account.unpair"; accountId: string } | { type: "account.unpair"; accountId: string }
| { type: "account.sync_groups"; accountId: string } | { type: "account.sync_groups"; accountId: string }
| { type: "group.send_test"; groupId: string; text: string } | { type: "group.send_test"; groupId: string; text: string }
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }; | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
| { type: "reminder.resume"; reminderId: string; runId: string };
export async function pgNotifyBot(cmd: BotCommand): Promise<void> { export async function pgNotifyBot(cmd: BotCommand): Promise<void> {
const json = JSON.stringify(cmd); const json = JSON.stringify(cmd);

View File

@ -34,7 +34,7 @@ export async function getDashboardStats(operatorId: string) {
totalAccounts: accounts.length, totalAccounts: accounts.length,
activeReminders: allReminders.filter((r) => r.status === "active").length, activeReminders: allReminders.filter((r) => r.status === "active").length,
pausedReminders: allReminders.filter((r) => r.status === "paused").length, pausedReminders: allReminders.filter((r) => r.status === "paused").length,
endedReminders: allReminders.filter((r) => r.status === "ended").length, inactiveReminders: allReminders.filter((r) => r.status === "inactive").length,
totalReminders: allReminders.length, totalReminders: allReminders.length,
recentRuns: recentRuns.rows as Array<{ recentRuns: recentRuns.rows as Array<{
id: string; id: string;
@ -241,11 +241,23 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
where: (m, { eq }) => eq(m.reminderId, reminderId), where: (m, { eq }) => eq(m.reminderId, reminderId),
orderBy: (m, { asc }) => [asc(m.position)], orderBy: (m, { asc }) => [asc(m.position)],
}); });
// LEFT-JOIN aggregate counts in one round-trip so the detail page
// can render the paused banner with "X of Y groups delivered"
// without a per-run fan-out query. Counts are bigint in PG → cast
// to int so JSON marshalling stays lossless.
const runs = await db.execute(sql` const runs = await db.execute(sql`
SELECT id, fired_at, status, error_summary SELECT
FROM reminder_runs rr.id,
WHERE reminder_id = ${reminderId} rr.fired_at,
ORDER BY fired_at DESC rr.status,
rr.error_summary,
COALESCE(SUM(CASE WHEN rt.status = 'sent' THEN 1 ELSE 0 END)::int, 0) AS sent,
COALESCE(COUNT(rt.id)::int, 0) AS total
FROM reminder_runs rr
LEFT JOIN reminder_run_targets rt ON rt.run_id = rr.id
WHERE rr.reminder_id = ${reminderId}
GROUP BY rr.id, rr.fired_at, rr.status, rr.error_summary
ORDER BY rr.fired_at DESC
LIMIT 20 LIMIT 20
`); `);
return { return {
@ -261,6 +273,8 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
firedAt: r.fired_at as Date, firedAt: r.fired_at as Date,
status: r.status as string, status: r.status as string,
errorSummary: r.error_summary as string | null, errorSummary: r.error_summary as string | null,
sent: r.sent as number,
total: r.total as number,
})), })),
}; };
} }

View File

@ -73,7 +73,7 @@ describe("applyReminderFilter — account / group filters", () => {
}); });
it("status='all' or unset includes every status", () => { it("status='all' or unset includes every status", () => {
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "ended" })]; const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "inactive" })];
expect(applyReminderFilter(rows, { status: "all" }).map((r) => r.id)).toEqual(["a", "b"]); expect(applyReminderFilter(rows, { status: "all" }).map((r) => r.id)).toEqual(["a", "b"]);
expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual(["a", "b"]); expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual(["a", "b"]);
}); });
@ -81,7 +81,7 @@ describe("applyReminderFilter — account / group filters", () => {
it("status filters to the matching value", () => { it("status filters to the matching value", () => {
const rows = [ const rows = [
mk({ id: "a", status: "active" }), mk({ id: "a", status: "active" }),
mk({ id: "b", status: "ended" }), mk({ id: "b", status: "inactive" }),
mk({ id: "c", status: "paused" }), mk({ id: "c", status: "paused" }),
]; ];
expect(applyReminderFilter(rows, { status: "paused" }).map((r) => r.id)).toEqual(["c"]); expect(applyReminderFilter(rows, { status: "paused" }).map((r) => r.id)).toEqual(["c"]);
@ -152,7 +152,7 @@ describe("applyReminderFilter — combined", () => {
mk({ id: "match", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }), mk({ id: "match", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
mk({ id: "wrong-acc", name: "Daily ping", accountId: "acc-2", groupIds: ["g-1"], status: "active", ...base }), mk({ id: "wrong-acc", name: "Daily ping", accountId: "acc-2", groupIds: ["g-1"], status: "active", ...base }),
mk({ id: "wrong-group", name: "Daily ping", accountId: "acc-1", groupIds: ["g-9"], status: "active", ...base }), mk({ id: "wrong-group", name: "Daily ping", accountId: "acc-1", groupIds: ["g-9"], status: "active", ...base }),
mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "ended", ...base }), mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "inactive", ...base }),
mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }), mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
]; ];
expect( expect(

View File

@ -26,7 +26,7 @@ export interface ReminderFilter {
q?: string; q?: string;
accountId?: string; accountId?: string;
groupId?: string; groupId?: string;
status?: string; // "all" | "active" | "ended" | "paused" status?: string; // "all" | "active" | "inactive" | "paused"
sort?: SortKey; sort?: SortKey;
} }

View File

@ -59,11 +59,11 @@ describe("validateUpdateScheduledAt", () => {
if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime()); if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime());
}); });
it("ended one-off, past timestamp matching existing → ALLOWED", () => { it("inactive one-off, past timestamp matching existing → ALLOWED", () => {
const r = validateUpdateScheduledAt({ const r = validateUpdateScheduledAt({
iso: isoOf(PAST), iso: isoOf(PAST),
timezone: TZ, timezone: TZ,
existingStatus: "ended", existingStatus: "inactive",
existingScheduledAt: PAST, existingScheduledAt: PAST,
now: NOW, now: NOW,
}); });

View File

@ -34,7 +34,7 @@ export function validateUpdateScheduledAt(args: {
if (Number.isNaN(dt.getTime())) { if (Number.isNaN(dt.getTime())) {
return { ok: false, error: "Invalid date" }; return { ok: false, error: "Invalid date" };
} }
const isPaused = args.existingStatus === "paused" || args.existingStatus === "ended"; const isPaused = args.existingStatus === "paused" || args.existingStatus === "inactive";
const sameAsExisting = const sameAsExisting =
args.existingScheduledAt !== null && args.existingScheduledAt !== null &&
Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000; Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000;

View File

@ -0,0 +1,4 @@
-- Rename the reminders.status enum value 'ended' → 'inactive'.
-- The column is plain text (no DB-level enum), so this is purely a
-- data migration. Code path renames in the same commit.
UPDATE reminders SET status = 'inactive' WHERE status = 'ended';

View File

@ -64,6 +64,13 @@
"when": 1778395584234, "when": 1778395584234,
"tag": "0008_greedy_matthew_murdock", "tag": "0008_greedy_matthew_murdock",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1778464000000,
"tag": "0009_rename_ended_to_inactive",
"breakpoints": true
} }
] ]
} }