Compare commits
No commits in common. "4cb401566641f732f92543739b3911497f688108" and "670eaf493cdb39e3abc30cb9c4759e14cd3a8785" have entirely different histories.
4cb4015666
...
670eaf493c
@ -9,7 +9,6 @@ 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");
|
||||||
@ -23,7 +22,6 @@ 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> => {
|
||||||
|
|||||||
@ -6,18 +6,14 @@ 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 {
|
import { handleScheduleReminder } from "./schedule-reminder-handler.js";
|
||||||
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> } = {};
|
||||||
@ -83,7 +79,4 @@ 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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,17 +11,7 @@ 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.
|
||||||
|
|||||||
@ -1,16 +1,6 @@
|
|||||||
import { getBoss } from "../scheduler/pgboss-client.js";
|
import { getBoss } from "../scheduler/pgboss-client.js";
|
||||||
import {
|
import { scheduleReminderFire } from "../scheduler/reminder-jobs.js";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -18,24 +18,13 @@ 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: () => ({
|
insert: () => ({ values: () => ({ returning: async () => [{ id: "run-1" }] }) }),
|
||||||
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) },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -54,7 +43,6 @@ 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 () => {
|
||||||
@ -68,8 +56,6 @@ 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",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,15 +69,13 @@ describe("fireReminder", () => {
|
|||||||
getReminderMock.mockResolvedValue({
|
getReminderMock.mockResolvedValue({
|
||||||
id: "r-1",
|
id: "r-1",
|
||||||
accountId: "acct-A",
|
accountId: "acct-A",
|
||||||
status: "inactive",
|
status: "ended",
|
||||||
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",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -108,66 +92,6 @@ 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",
|
||||||
@ -179,8 +103,6 @@ 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({
|
||||||
@ -193,8 +115,6 @@ describe("fireReminder", () => {
|
|||||||
scheduleKind: "one_off",
|
scheduleKind: "one_off",
|
||||||
rrule: null,
|
rrule: null,
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
deliveryWindowStartHour: 6,
|
|
||||||
deliveryWindowEndHour: 18,
|
|
||||||
name: "B",
|
name: "B",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -12,12 +12,7 @@ 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 {
|
import { absoluteMediaPath, nextOccurrence, resolveDeliveryKind } from "@cmbot/shared";
|
||||||
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";
|
||||||
@ -28,23 +23,7 @@ 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 = {
|
export type FireReminderPayload = { reminderId: string };
|
||||||
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. */
|
||||||
@ -85,75 +64,20 @@ 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;
|
||||||
}
|
}
|
||||||
// Resumes are allowed even when the reminder's lifecycle status is
|
if (reminder.status !== "active") {
|
||||||
// 'paused' — we WANT to take a paused reminder back to active mid-
|
logger.info({ reminderId: reminder.id, status: reminder.status }, "fire-reminder: skipping (not active)");
|
||||||
// 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, payload.runId));
|
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fireReminderInner(
|
async function fireReminderInner(
|
||||||
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
|
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
|
||||||
resumeRunId?: string,
|
|
||||||
): Promise<void> {
|
): 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
|
const [run] = await db
|
||||||
.insert(reminderRuns)
|
.insert(reminderRuns)
|
||||||
.values({
|
.values({
|
||||||
@ -162,25 +86,17 @@ async function fireReminderInner(
|
|||||||
status: "pending",
|
status: "pending",
|
||||||
})
|
})
|
||||||
.returning({ id: reminderRuns.id });
|
.returning({ id: reminderRuns.id });
|
||||||
runId = run!.id;
|
const 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");
|
||||||
if (!resumeRunId) {
|
|
||||||
await markAllSkipped(runId, reminder, "account not connected");
|
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({
|
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status: "skipped" });
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: reminder.id,
|
|
||||||
runId,
|
|
||||||
status: "skipped",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,9 +115,8 @@ 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 on the fresh path so the Activity tab
|
// Pre-create run_target rows so the Activity tab shows progress mid-run.
|
||||||
// shows progress mid-run. Resume reuses the existing rows.
|
if (reminder.targets.length > 0) {
|
||||||
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,
|
||||||
@ -212,44 +127,11 @@ async function fireReminderInner(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On resume, only the still-pending rows are processed. On a fresh
|
// Per-run media upload cache. Each unique mediaId is prepared via
|
||||||
// fire that's every row since we just inserted them all as pending.
|
// generateWAMessageContent ONCE (which uploads to WA's CDN through
|
||||||
const pendingRows = await db.query.reminderRunTargets.findMany({
|
// the socket's waUploadToServer); the resulting proto.Message is
|
||||||
where: (t, { eq: dEq, and: dAnd }) => dAnd(dEq(t.runId, runId), dEq(t.status, "pending")),
|
// reused for every group via socket.relayMessage. For 1000 groups
|
||||||
});
|
// × 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}`);
|
||||||
@ -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);
|
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(
|
||||||
targetsToProcess.map((target) =>
|
reminder.targets.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
|
||||||
@ -312,6 +187,8 @@ 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;
|
||||||
@ -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 total = reminder.targets.length;
|
||||||
const totalSent = priorSentCount + sentCount;
|
let status: "success" | "partial" | "failed";
|
||||||
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 (windowClosed && remainingPending > 0 && totalSent > 0) {
|
if (sentCount === total) {
|
||||||
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 (totalSent > 0) {
|
} else if (sentCount > 0) {
|
||||||
status = "partial";
|
status = "partial";
|
||||||
errorSummary = `${totalSent} of ${total} groups delivered (${totalFailed} failed, ${skippedCount} skipped).`;
|
errorSummary = `${sentCount} of ${total} groups delivered (${failedCount} 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.`;
|
||||||
@ -406,45 +260,18 @@ async function fireReminderInner(
|
|||||||
.set({ status, errorSummary })
|
.set({ status, errorSummary })
|
||||||
.where(eq(reminderRuns.id, runId));
|
.where(eq(reminderRuns.id, runId));
|
||||||
|
|
||||||
await pgNotifyWeb({
|
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status });
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: reminder.id,
|
|
||||||
runId,
|
|
||||||
status,
|
|
||||||
sent: totalSent,
|
|
||||||
total,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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") {
|
if (reminder.scheduleKind === "one_off") {
|
||||||
await db
|
await db
|
||||||
.update(reminders)
|
.update(reminders)
|
||||||
.set({ status: "inactive", updatedAt: new Date() })
|
.set({ status: "ended", updatedAt: new Date() })
|
||||||
.where(eq(reminders.id, reminder.id));
|
.where(eq(reminders.id, reminder.id));
|
||||||
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
||||||
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
|
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
|
||||||
await db
|
await db
|
||||||
.update(reminders)
|
.update(reminders)
|
||||||
.set({
|
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
|
||||||
// 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));
|
.where(eq(reminders.id, reminder.id));
|
||||||
if (next) {
|
if (next) {
|
||||||
try {
|
try {
|
||||||
@ -455,8 +282,7 @@ async function fireReminderInner(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
|
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(
|
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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,7 @@ 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> {
|
||||||
// 'stately' = at most 1 job per (state, singletonKey). Combined with
|
await boss.createQueue(REMINDER_FIRE_QUEUE);
|
||||||
// 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,
|
||||||
{
|
{
|
||||||
@ -59,31 +50,6 @@ 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
|
||||||
|
|||||||
@ -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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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 };
|
|
||||||
}
|
|
||||||
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -6,13 +6,7 @@ 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 {
|
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
|
||||||
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";
|
||||||
@ -544,129 +538,3 @@ 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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,8 +6,6 @@ import {
|
|||||||
ArchiveRestoreIcon,
|
ArchiveRestoreIcon,
|
||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
MinusCircleIcon,
|
MinusCircleIcon,
|
||||||
PauseCircleIcon,
|
|
||||||
PlayIcon,
|
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -43,7 +41,6 @@ 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;
|
||||||
@ -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",
|
"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:
|
||||||
@ -106,18 +97,10 @@ function RunStatusBadge({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterValue =
|
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped" | "archived";
|
||||||
| "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" },
|
||||||
@ -184,7 +167,6 @@ 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" ||
|
||||||
@ -372,9 +354,6 @@ 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
|
||||||
|
|||||||
@ -182,9 +182,9 @@ export default async function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Reminders"
|
title="Reminders"
|
||||||
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.inactiveReminders} / ${stats.totalReminders}`}
|
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.endedReminders} / ${stats.totalReminders}`}
|
||||||
icon={BellIcon}
|
icon={BellIcon}
|
||||||
description="Active / Paused / Inactive / Total"
|
description="Active / Paused / Ended / Total"
|
||||||
href="/reminders"
|
href="/reminders"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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("inactive: shows Restart and Delete (no Pause)", () => {
|
it("ended: shows Restart and Delete (no Pause)", () => {
|
||||||
const html = renderToStaticMarkup(
|
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="Restart"/);
|
||||||
expect(html).toMatch(/aria-label="Delete"/);
|
expect(html).toMatch(/aria-label="Delete"/);
|
||||||
|
|||||||
@ -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 "inactive"
|
* - Restart — when status is "paused" or "ended"
|
||||||
* - 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 === "inactive";
|
const canRestart = status === "paused" || status === "ended";
|
||||||
|
|
||||||
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">
|
||||||
|
|||||||
@ -30,7 +30,6 @@ 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 {
|
||||||
@ -48,7 +47,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",
|
||||||
inactive:
|
ended:
|
||||||
"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",
|
||||||
@ -120,22 +119,6 @@ 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
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import {
|
|||||||
restartReminderAction,
|
restartReminderAction,
|
||||||
} from "@/actions/reminders";
|
} from "@/actions/reminders";
|
||||||
|
|
||||||
type FilterValue = "all" | "active" | "inactive" | "paused";
|
type FilterValue = "all" | "active" | "ended" | "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",
|
||||||
inactive:
|
ended:
|
||||||
"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: "inactive", label: "Inactive" },
|
{ value: "ended", label: "Ended" },
|
||||||
{ 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 === "inactive" || sp.filter === "paused"
|
sp.filter === "active" || sp.filter === "ended" || 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 === "inactive";
|
reminder.status === "paused" || reminder.status === "ended";
|
||||||
const cardBody = (
|
const cardBody = (
|
||||||
<Link
|
<Link
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@ -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"/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -52,15 +52,7 @@ 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);
|
||||||
// Optional deadline: 24 (next-day midnight) is the off-sentinel —
|
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(initialDeliveryEndHour);
|
||||||
// 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);
|
||||||
|
|
||||||
@ -116,7 +108,7 @@ export function EditWhenForm({
|
|||||||
scheduledAtIso,
|
scheduledAtIso,
|
||||||
rrule,
|
rrule,
|
||||||
timezone,
|
timezone,
|
||||||
deliveryWindowEndHour: useDeadline ? deliveryEndHour : 24,
|
deliveryWindowEndHour: deliveryEndHour,
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -170,29 +162,13 @@ export function EditWhenForm({
|
|||||||
|
|
||||||
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
|
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-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">
|
<Label className="flex items-center gap-1.5">
|
||||||
<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">
|
|
||||||
<ClockIcon className="size-3.5" />
|
<ClockIcon className="size-3.5" />
|
||||||
Pause sending by
|
Pause sending by
|
||||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||||
</span>
|
</Label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{useDeadline ? "Set" : "Off"}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{useDeadline && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-3">
|
|
||||||
<HourSelect
|
<HourSelect
|
||||||
ariaPrefix="Delivery deadline"
|
ariaPrefix="Delivery deadline"
|
||||||
value={deliveryEndHour}
|
value={deliveryEndHour}
|
||||||
@ -203,7 +179,6 @@ export function EditWhenForm({
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">({timezone})</span>
|
<span className="text-xs text-muted-foreground">({timezone})</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@ -82,12 +82,8 @@ 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);
|
||||||
// Treat hour=24 (or unset) as "no deadline". The pill goes neutral.
|
const endHour = deliveryEndHour ?? 18;
|
||||||
const hasDeadline =
|
const wEnd = windowEndAt(timezone, endHour, fireAt);
|
||||||
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">
|
||||||
|
|||||||
@ -3,20 +3,6 @@ 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
|
||||||
|
|||||||
@ -4,10 +4,7 @@ import { estimateRunDuration } from "@/lib/run-eta";
|
|||||||
interface RunEtaPillProps {
|
interface RunEtaPillProps {
|
||||||
targetCount: number;
|
targetCount: number;
|
||||||
fireAt: Date;
|
fireAt: Date;
|
||||||
/** Optional. When omitted (or when the operator picked "no
|
windowEndAt: Date;
|
||||||
* deadline"), the pill renders a neutral ETA without the
|
|
||||||
* green/amber fit indicator. */
|
|
||||||
windowEndAt?: Date;
|
|
||||||
timezone: string;
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +27,8 @@ 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",
|
||||||
@ -37,23 +36,6 @@ 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">
|
||||||
|
|||||||
@ -45,16 +45,8 @@ 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>(
|
||||||
initialUseDeadline ? (initialDeliveryEndHour ?? 18) : 18,
|
initialDeliveryEndHour ?? 18,
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -85,8 +77,7 @@ 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);
|
||||||
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
|
sp.set("deliveryEndHour", String(deliveryEndHour));
|
||||||
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;
|
||||||
@ -130,8 +121,7 @@ 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);
|
||||||
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
|
sp.set("deliveryEndHour", String(deliveryEndHour));
|
||||||
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);
|
||||||
}
|
}
|
||||||
@ -178,32 +168,14 @@ 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. */}
|
||||||
The whole control is opt-in: tick the box to surface the hour
|
<div className="space-y-1.5">
|
||||||
picker, untick to remove the deadline entirely. */}
|
<Label className="flex items-center gap-1.5">
|
||||||
<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">
|
|
||||||
<ClockIcon className="size-3.5" />
|
<ClockIcon className="size-3.5" />
|
||||||
Pause sending by
|
Pause sending by
|
||||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||||
</span>
|
</Label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{useDeadline ? "Set" : "Off"}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{useDeadline && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-3">
|
|
||||||
<HourSelect
|
<HourSelect
|
||||||
ariaPrefix="Delivery deadline"
|
ariaPrefix="Delivery deadline"
|
||||||
value={deliveryEndHour}
|
value={deliveryEndHour}
|
||||||
@ -214,7 +186,6 @@ export function WhenFormClient({
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">({timezone})</span>
|
<span className="text-xs text-muted-foreground">({timezone})</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@ -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)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -10,13 +10,7 @@ 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": {
|
"reminder.fired": { reminderId: string; runId: string; status: string };
|
||||||
reminderId: string;
|
|
||||||
runId: string;
|
|
||||||
status: string;
|
|
||||||
sent?: number;
|
|
||||||
total?: number;
|
|
||||||
};
|
|
||||||
"reminder.failed": { reminderId: string; error: string };
|
"reminder.failed": { reminderId: string; error: string };
|
||||||
"send_test.done": { groupId: string; ok: boolean; error: string | null };
|
"send_test.done": { groupId: string; ok: boolean; error: string | null };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -240,44 +240,6 @@ describe("reminderFiredToNotification mapping", () => {
|
|||||||
expect(args).toBeNull();
|
expect(args).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders 'paused' with the resume/cancel call-to-action and sent/total", () => {
|
|
||||||
const args = reminderFiredToNotification({
|
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: "r-p",
|
|
||||||
runId: "run-p",
|
|
||||||
status: "paused",
|
|
||||||
sent: 412,
|
|
||||||
total: 1000,
|
|
||||||
});
|
|
||||||
expect(args?.title).toBe("Reminder paused");
|
|
||||||
expect(args?.body).toBe("412 of 1000 groups delivered. Tap to resume or cancel.");
|
|
||||||
expect(args?.tag).toBe("reminder:r-p");
|
|
||||||
expect(args?.href).toBe("/reminders/r-p");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders 'paused' without sent/total with a generic body", () => {
|
|
||||||
const args = reminderFiredToNotification({
|
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: "r-p",
|
|
||||||
runId: "run-p",
|
|
||||||
status: "paused",
|
|
||||||
});
|
|
||||||
expect(args?.title).toBe("Reminder paused");
|
|
||||||
expect(args?.body).toMatch(/Delivery window closed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders 'partial' with sent/total → 'X of Y groups delivered'", () => {
|
|
||||||
const args = reminderFiredToNotification({
|
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: "r-2",
|
|
||||||
runId: "run-2",
|
|
||||||
status: "partial",
|
|
||||||
sent: 87,
|
|
||||||
total: 100,
|
|
||||||
});
|
|
||||||
expect(args?.body).toBe("87 of 100 groups delivered. See activity for details.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the same tag for repeat fires of the same reminder so they coalesce", () => {
|
it("uses the same tag for repeat fires of the same reminder so they coalesce", () => {
|
||||||
const a = reminderFiredToNotification({
|
const a = reminderFiredToNotification({
|
||||||
type: "reminder.fired",
|
type: "reminder.fired",
|
||||||
|
|||||||
@ -138,35 +138,20 @@ 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 === "paused"
|
|
||||||
? "Reminder paused"
|
|
||||||
: event.status === "partial"
|
: event.status === "partial"
|
||||||
? "Reminder partly sent"
|
? "Reminder partly sent"
|
||||||
: "Reminder failed";
|
: "Reminder failed";
|
||||||
let body =
|
const body =
|
||||||
event.status === "success"
|
event.status === "success"
|
||||||
? "All groups received the message."
|
? "All groups received the message."
|
||||||
: event.status === "paused"
|
|
||||||
? "Delivery window closed before all groups got the message."
|
|
||||||
: event.status === "partial"
|
: event.status === "partial"
|
||||||
? "Some groups received the message; others failed. See activity."
|
? "Some groups received the message; others failed. See activity."
|
||||||
: "No groups received the message. 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,
|
||||||
|
|||||||
@ -7,8 +7,7 @@ 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);
|
||||||
|
|||||||
@ -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,
|
||||||
inactiveReminders: allReminders.filter((r) => r.status === "inactive").length,
|
endedReminders: allReminders.filter((r) => r.status === "ended").length,
|
||||||
totalReminders: allReminders.length,
|
totalReminders: allReminders.length,
|
||||||
recentRuns: recentRuns.rows as Array<{
|
recentRuns: recentRuns.rows as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@ -241,23 +241,11 @@ 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
|
SELECT id, fired_at, status, error_summary
|
||||||
rr.id,
|
FROM reminder_runs
|
||||||
rr.fired_at,
|
WHERE reminder_id = ${reminderId}
|
||||||
rr.status,
|
ORDER BY fired_at DESC
|
||||||
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 {
|
||||||
@ -273,8 +261,6 @@ 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,
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: "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, { 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: "inactive" }),
|
mk({ id: "b", status: "ended" }),
|
||||||
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: "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 }),
|
mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
|
||||||
];
|
];
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export interface ReminderFilter {
|
|||||||
q?: string;
|
q?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
status?: string; // "all" | "active" | "inactive" | "paused"
|
status?: string; // "all" | "active" | "ended" | "paused"
|
||||||
sort?: SortKey;
|
sort?: SortKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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("inactive one-off, past timestamp matching existing → ALLOWED", () => {
|
it("ended one-off, past timestamp matching existing → ALLOWED", () => {
|
||||||
const r = validateUpdateScheduledAt({
|
const r = validateUpdateScheduledAt({
|
||||||
iso: isoOf(PAST),
|
iso: isoOf(PAST),
|
||||||
timezone: TZ,
|
timezone: TZ,
|
||||||
existingStatus: "inactive",
|
existingStatus: "ended",
|
||||||
existingScheduledAt: PAST,
|
existingScheduledAt: PAST,
|
||||||
now: NOW,
|
now: NOW,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 === "inactive";
|
const isPaused = args.existingStatus === "paused" || args.existingStatus === "ended";
|
||||||
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;
|
||||||
|
|||||||
@ -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';
|
|
||||||
@ -64,13 +64,6 @@
|
|||||||
"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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user