Compare commits

..

No commits in common. "4cb401566641f732f92543739b3911497f688108" and "670eaf493cdb39e3abc30cb9c4759e14cd3a8785" have entirely different histories.

38 changed files with 132 additions and 1514 deletions

View File

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

View File

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

View File

@ -11,17 +11,7 @@ export type WebEvent =
| { type: "session.disconnected"; accountId: string }
| { type: "session.timeout"; accountId: string }
| { type: "groups.synced"; accountId: string; count: number }
| {
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.fired"; reminderId: string; runId: string; status: string }
| { type: "reminder.failed"; reminderId: string; error: string }
// The web action enqueues a send_test via pg_notify and shows
// "Sending…" optimistically. This event closes the loop.

View File

@ -1,16 +1,6 @@
import { getBoss } from "../scheduler/pgboss-client.js";
import {
scheduleReminderFire,
enqueueReminderResume,
} from "../scheduler/reminder-jobs.js";
import { scheduleReminderFire } from "../scheduler/reminder-jobs.js";
export async function handleScheduleReminder(reminderId: string, scheduledAtIso: string): Promise<void> {
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,24 +18,13 @@ const getReminderMock = vi.fn();
vi.mock("../reminders/crud.js", () => ({
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", () => ({
db: {
insert: () => ({
values: () => ({
returning: async () => [{ id: "run-1" }],
}),
// Targets path: no .returning() chained.
values_no_returning: async () => undefined,
}),
insert: () => ({ values: () => ({ returning: async () => [{ id: "run-1" }] }) }),
update: () => ({ set: () => ({ where: async () => undefined }) }),
query: {
whatsappGroups: { findMany: async () => [] },
mediaFiles: { findMany: async () => [] },
reminderRunTargets: { findMany: async () => [] },
reminderRuns: { findFirst: (...args: unknown[]) => findExistingRunMock(...args) },
},
},
}));
@ -54,7 +43,6 @@ describe("fireReminder", () => {
beforeEach(() => {
vi.mocked(accountMutex.run).mockClear();
getReminderMock.mockReset();
findExistingRunMock.mockReset();
});
it("acquires accountMutex keyed by accountId for active reminders", async () => {
@ -68,8 +56,6 @@ describe("fireReminder", () => {
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test",
});
@ -83,15 +69,13 @@ describe("fireReminder", () => {
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "inactive",
status: "ended",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test",
});
@ -108,66 +92,6 @@ describe("fireReminder", () => {
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 () => {
getReminderMock.mockResolvedValueOnce({
id: "r-A",
@ -179,8 +103,6 @@ describe("fireReminder", () => {
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "A",
});
getReminderMock.mockResolvedValueOnce({
@ -193,8 +115,6 @@ describe("fireReminder", () => {
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "B",
});

View File

@ -12,12 +12,7 @@ import { readFile } from "node:fs/promises";
import { db } from "../db.js";
import { logger } from "../logger.js";
import { sessionManager } from "../whatsapp/session-manager.js";
import {
absoluteMediaPath,
nextOccurrence,
resolveDeliveryKind,
windowEndAt,
} from "@cmbot/shared";
import { absoluteMediaPath, nextOccurrence, resolveDeliveryKind } from "@cmbot/shared";
import { env } from "../env.js";
import { writeAuditLog } from "../audit.js";
import { getReminderWithDetails } from "../reminders/crud.js";
@ -28,23 +23,7 @@ import { accountMutex } from "./per-key-mutex.js";
import { accountRateLimiter } from "./rate-limiter.js";
import { MediaUploadCache } from "./media-upload-cache.js";
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;
export type FireReminderPayload = { reminderId: string };
/** Random delay between same-group message parts. Just enough for
* visible ordering in the chat at WA's natural pace. */
@ -85,75 +64,20 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found");
return;
}
// Resumes are allowed even when the reminder's lifecycle status is
// '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)",
);
if (reminder.status !== "active") {
logger.info({ reminderId: reminder.id, status: reminder.status }, "fire-reminder: skipping (not active)");
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
// (running them concurrently would double the effective send rate
// and risk a ban). Different accounts run in parallel.
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder, payload.runId));
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder));
}
async function fireReminderInner(
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
resumeRunId?: string,
): Promise<void> {
// Resume path attaches to the existing run row; fresh path inserts a new one.
let runId: string;
if (resumeRunId) {
const existing = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq }) => dEq(r.id, resumeRunId),
});
if (!existing) {
logger.warn(
{ 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({
@ -162,25 +86,17 @@ async function fireReminderInner(
status: "pending",
})
.returning({ id: reminderRuns.id });
runId = run!.id;
}
const runId = run!.id;
const session = sessionManager.getSession(reminder.accountId);
if (!session) {
logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected");
if (!resumeRunId) {
await markAllSkipped(runId, reminder, "account not connected");
}
await db
.update(reminderRuns)
.set({ status: "skipped", errorSummary: "account not connected" })
.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;
}
@ -199,9 +115,8 @@ async function fireReminderInner(
: [];
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
// Pre-create run_target rows on the fresh path so the Activity tab
// shows progress mid-run. Resume reuses the existing rows.
if (!resumeRunId && reminder.targets.length > 0) {
// Pre-create run_target rows so the Activity tab shows progress mid-run.
if (reminder.targets.length > 0) {
await db.insert(reminderRunTargets).values(
reminder.targets.map((t) => ({
runId,
@ -212,44 +127,11 @@ async function fireReminderInner(
);
}
// On resume, only the still-pending rows are processed. On a fresh
// fire that's every row since we just inserted them all as pending.
const pendingRows = await db.query.reminderRunTargets.findMany({
where: (t, { eq: dEq, and: dAnd }) => dAnd(dEq(t.runId, runId), dEq(t.status, "pending")),
});
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).
// Per-run media upload cache. Each unique mediaId is prepared via
// generateWAMessageContent ONCE (which uploads to WA's CDN through
// the socket's waUploadToServer); the resulting proto.Message is
// reused for every group via socket.relayMessage. For 1000 groups
// × 5 MB image, this turns 5 GB of upload into 5 MB.
const uploadCache = new MediaUploadCache<proto.IMessage>(async (mediaId) => {
const media = mediaById.get(mediaId);
if (!media) throw new Error(`media row missing: ${mediaId}`);
@ -275,26 +157,19 @@ async function fireReminderInner(
});
});
// Per-account rate limiter — gates each socket send.
// Per-account rate limiter — gates each socket send to stay within
// the account's safe band (BOT_MAX_SEND_PER_MINUTE, default 40).
const rateLimiter = accountRateLimiter.get(reminder.accountId);
let sentCount = 0;
let failedCount = 0;
let skippedCount = 0;
let windowClosed = false;
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
await Promise.all(
targetsToProcess.map((target) =>
reminder.targets.map((target) =>
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);
if (!group) {
await db
@ -312,6 +187,8 @@ async function fireReminderInner(
const start = Date.now();
try {
// Once per group, before the first send. sendMessage handles
// sessions internally; relayMessage does not.
await ensureGroupSessions(session.socket, group.waGroupJid);
let lastMessageId: string | undefined;
@ -365,37 +242,14 @@ 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 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 status: "success" | "partial" | "failed";
let errorSummary: string | null = null;
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) {
if (sentCount === total) {
status = "success";
} else if (totalSent > 0) {
} else if (sentCount > 0) {
status = "partial";
errorSummary = `${totalSent} of ${total} groups delivered (${totalFailed} failed, ${skippedCount} skipped).`;
errorSummary = `${sentCount} of ${total} groups delivered (${failedCount} failed, ${skippedCount} skipped).`;
} else {
status = "failed";
errorSummary = total === 0 ? "No targets attached to reminder." : `All ${total} sends failed.`;
@ -406,45 +260,18 @@ async function fireReminderInner(
.set({ status, errorSummary })
.where(eq(reminderRuns.id, runId));
await pgNotifyWeb({
type: "reminder.fired",
reminderId: reminder.id,
runId,
status,
sent: totalSent,
total,
});
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status });
// 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
.update(reminders)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(reminders.id, reminder.id));
logger.info(
{ reminderId: reminder.id, runId, totalSent, remainingPending },
"fire-reminder: paused — leaving lifecycle alone for resume",
);
} else {
if (reminder.scheduleKind === "one_off") {
await db
.update(reminders)
.set({ status: "inactive", updatedAt: new Date() })
.set({ status: "ended", updatedAt: new Date() })
.where(eq(reminders.id, reminder.id));
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
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(),
})
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
.where(eq(reminders.id, reminder.id));
if (next) {
try {
@ -455,8 +282,7 @@ async function fireReminderInner(
}
} 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));
}
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
}
}
@ -470,15 +296,7 @@ async function fireReminderInner(
});
logger.info(
{
reminderId: reminder.id,
runId,
status,
sent: sentCount,
failed: failedCount,
skipped: skippedCount,
windowClosed,
},
{ reminderId: reminder.id, runId, status, sent: sentCount, failed: failedCount, skipped: skippedCount },
"fire-reminder: done",
);
}

View File

@ -6,16 +6,7 @@ import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
export const REMINDER_FIRE_QUEUE = "reminder.fire";
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
// '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.createQueue(REMINDER_FIRE_QUEUE);
await boss.work<FireReminderPayload>(
REMINDER_FIRE_QUEUE,
{
@ -59,31 +50,6 @@ export async function scheduleReminderFire(
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> {
// 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

View File

@ -1,76 +0,0 @@
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

@ -1,64 +0,0 @@
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

@ -1,211 +0,0 @@
/**
* 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,13 +6,7 @@ import { headers } from "next/headers";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { DateTime } from "luxon";
import {
reminders,
reminderTargets,
reminderMessages,
reminderRuns,
reminderRunTargets,
} from "@cmbot/db";
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
@ -544,129 +538,3 @@ export async function updateReminderAction(
revalidatePath(`/reminders/${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,8 +6,6 @@ import {
ArchiveRestoreIcon,
CheckCircle2Icon,
MinusCircleIcon,
PauseCircleIcon,
PlayIcon,
Trash2Icon,
XCircleIcon,
} from "lucide-react";
@ -43,7 +41,6 @@ import {
unarchiveRunAction,
} from "@/actions/history";
import { SwipeableRow } from "@/components/swipeable-row";
import { ResumeRunButton } from "@/components/activity/resume-run-button";
function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
@ -65,12 +62,6 @@ const RUN_STATUS_CONFIG: Record<
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
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: {
label: "Partial",
className:
@ -106,18 +97,10 @@ function RunStatusBadge({ status }: { status: string }) {
);
}
type FilterValue =
| "all"
| "success"
| "paused"
| "partial"
| "failed"
| "skipped"
| "archived";
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped" | "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" },
{ value: "paused", label: "Paused" },
{ value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
@ -184,7 +167,6 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filter: FilterValue =
sp.filter === "success" ||
sp.filter === "paused" ||
sp.filter === "partial" ||
sp.filter === "failed" ||
sp.filter === "skipped" ||
@ -372,9 +354,6 @@ export default async function ActivityPage({ searchParams }: PageProps) {
</TableCell>
<TableCell className="text-right pr-2 whitespace-nowrap">
<div className="inline-flex items-center gap-0.5">
{run.status === "paused" && (
<ResumeRunButton runId={run.id} />
)}
<form
action={
isArchived ? unarchiveRunAction : archiveRunAction

View File

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

View File

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

View File

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

View File

@ -30,7 +30,6 @@ import {
} from "@/components/ui/table";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { PausedRunBanner } from "@/components/reminder-detail/paused-run-banner";
import { ActionsBar } from "./actions-bar";
function formatWhen(date: Date | null, tz: string): string {
@ -48,7 +47,7 @@ function formatWhen(date: Date | null, tz: string): string {
const STATUS_STYLES: Record<string, string> = {
active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
inactive:
ended:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
@ -120,22 +119,6 @@ export default async function ReminderDetailPage({ params }: Props) {
</p>
</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 />
{/* Name click to edit. Required field, the operator's

View File

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

View File

@ -1,32 +0,0 @@
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

@ -1,54 +0,0 @@
"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

@ -1,71 +0,0 @@
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

@ -1,118 +0,0 @@
"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,15 +52,7 @@ export function EditWhenForm({
const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time);
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
// 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 [deliveryEndHour, setDeliveryEndHour] = useState<number>(initialDeliveryEndHour);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -116,7 +108,7 @@ export function EditWhenForm({
scheduledAtIso,
rrule,
timezone,
deliveryWindowEndHour: useDeadline ? deliveryEndHour : 24,
deliveryWindowEndHour: deliveryEndHour,
});
if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -170,29 +162,13 @@ export function EditWhenForm({
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
<div className="space-y-2">
<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">
<input
type="checkbox"
checked={useDeadline}
onChange={(e) => {
setUseDeadline(e.target.checked);
setError(null);
}}
className="size-5 rounded border-input accent-primary"
aria-label="Set a delivery deadline"
/>
<span className="flex-1 flex items-center gap-1.5 text-sm font-medium">
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
<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">
</Label>
<div className="flex flex-wrap items-center gap-2">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
@ -203,7 +179,6 @@ export function EditWhenForm({
/>
<span className="text-xs text-muted-foreground">({timezone})</span>
</div>
)}
</div>
{error && (

View File

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

View File

@ -3,20 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server";
import { RunEtaPill } from "./run-eta-pill";
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", () => {
const html = renderToStaticMarkup(
<RunEtaPill

View File

@ -4,10 +4,7 @@ import { estimateRunDuration } from "@/lib/run-eta";
interface RunEtaPillProps {
targetCount: number;
fireAt: 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;
windowEndAt: Date;
timezone: string;
}
@ -30,6 +27,8 @@ export function RunEtaPill({
targetCount,
fireAt,
});
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
const finishLocal = new Intl.DateTimeFormat("en-GB", {
hour: "numeric",
minute: "2-digit",
@ -37,23 +36,6 @@ export function RunEtaPill({
timeZone: timezone,
}).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) {
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">

View File

@ -45,16 +45,8 @@ export function WhenFormClient({
const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time);
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>(
initialUseDeadline ? (initialDeliveryEndHour ?? 18) : 18,
initialDeliveryEndHour ?? 18,
);
const [error, setError] = useState<string | null>(null);
@ -85,8 +77,7 @@ export function WhenFormClient({
if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
sp.set("deliveryEndHour", String(deliveryEndHour));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any);
return;
@ -130,8 +121,7 @@ export function WhenFormClient({
if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
sp.set("deliveryEndHour", String(deliveryEndHour));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any);
}
@ -178,32 +168,14 @@ export function WhenFormClient({
{/* Deadline fire time is the implicit start; this only sets when
the bot must stop. Long fan-outs that don't finish before the
deadline are paused so the operator can resume them later.
The whole control is opt-in: tick the box to surface the hour
picker, untick to remove the deadline entirely. */}
<div className="space-y-2">
<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">
<input
type="checkbox"
checked={useDeadline}
onChange={(e) => {
setUseDeadline(e.target.checked);
setError(null);
}}
className="size-5 rounded border-input accent-primary"
aria-label="Set a delivery deadline"
/>
<span className="flex-1 flex items-center gap-1.5 text-sm font-medium">
deadline are paused so the operator can resume them later. */}
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
<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">
</Label>
<div className="flex flex-wrap items-center gap-2">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
@ -214,7 +186,6 @@ export function WhenFormClient({
/>
<span className="text-xs text-muted-foreground">({timezone})</span>
</div>
)}
</div>
{error && (

View File

@ -1,86 +0,0 @@
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,13 +10,7 @@ export type WebEventMap = {
"session.disconnected": { accountId: string };
"session.timeout": { accountId: string };
"groups.synced": { accountId: string; count: number };
"reminder.fired": {
reminderId: string;
runId: string;
status: string;
sent?: number;
total?: number;
};
"reminder.fired": { reminderId: string; runId: string; status: string };
"reminder.failed": { reminderId: string; error: string };
"send_test.done": { groupId: string; ok: boolean; error: string | null };
};

View File

@ -240,44 +240,6 @@ describe("reminderFiredToNotification mapping", () => {
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", () => {
const a = reminderFiredToNotification({
type: "reminder.fired",

View File

@ -138,35 +138,20 @@ export function reminderFiredToNotification(event: {
reminderId: string;
runId: string;
status: string;
sent?: number;
total?: number;
}): ShowNotificationOptions | null {
if (event.status === "skipped") return null;
const headline =
event.status === "success"
? "Reminder sent"
: event.status === "paused"
? "Reminder paused"
: event.status === "partial"
? "Reminder partly sent"
: "Reminder failed";
let body =
const body =
event.status === "success"
? "All groups received the message."
: event.status === "paused"
? "Delivery window closed before all groups got the message."
: 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 {
title: headline,
body,

View File

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

View File

@ -34,7 +34,7 @@ export async function getDashboardStats(operatorId: string) {
totalAccounts: accounts.length,
activeReminders: allReminders.filter((r) => r.status === "active").length,
pausedReminders: allReminders.filter((r) => r.status === "paused").length,
inactiveReminders: allReminders.filter((r) => r.status === "inactive").length,
endedReminders: allReminders.filter((r) => r.status === "ended").length,
totalReminders: allReminders.length,
recentRuns: recentRuns.rows as Array<{
id: string;
@ -241,23 +241,11 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
where: (m, { eq }) => eq(m.reminderId, reminderId),
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`
SELECT
rr.id,
rr.fired_at,
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
SELECT id, fired_at, status, error_summary
FROM reminder_runs
WHERE reminder_id = ${reminderId}
ORDER BY fired_at DESC
LIMIT 20
`);
return {
@ -273,8 +261,6 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
firedAt: r.fired_at as Date,
status: r.status as string,
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", () => {
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "inactive" })];
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "ended" })];
expect(applyReminderFilter(rows, { status: "all" }).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", () => {
const rows = [
mk({ id: "a", status: "active" }),
mk({ id: "b", status: "inactive" }),
mk({ id: "b", status: "ended" }),
mk({ id: "c", status: "paused" }),
];
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: "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-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "inactive", ...base }),
mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "ended", ...base }),
mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
];
expect(

View File

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

View File

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

View File

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

View File

@ -1,4 +0,0 @@
-- 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,13 +64,6 @@
"when": 1778395584234,
"tag": "0008_greedy_matthew_murdock",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1778464000000,
"tag": "0009_rename_ended_to_inactive",
"breakpoints": true
}
]
}