yiekheng e800882d15 fix: 'Pause sending by' is off by default everywhere
The optional 'Pause sending by' deadline was defaulting to 18 (= 6 PM)
in three places:
  - reminders.delivery_window_end_hour schema default (NOT NULL DEFAULT 18)
  - createReminderAction / editScheduleAction fallback when the field
    is missing on the input
  - the Zod refine validator's secondary fallback

Net effect: any reminder created before this change has 18 in the DB,
so the edit form's checkbox flips ON automatically (the wizard treats
'value !== undefined && value !== 24' as 'opted in'). The wizard's
own create flow always sends 24 explicitly when the box is unchecked
— but legacy / direct API payloads + the schema default for older rows
don't carry that intent through.

Switch every default to 24 (the off-sentinel the wizard already uses)
so the optional toggle stays off until the operator ticks it. New
migration 0012 also backfills existing rows from 18 → 24 so editing
old reminders no longer auto-checks 'Pause sending by'.

Tests in when-form-deadline.test.tsx already lock in the UI contract
(off when initialDeliveryEndHour is undefined or 24, on for any other
value). No assertion changes needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:30:09 +08:00

693 lines
24 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { DateTime } from "luxon";
import {
reminders,
reminderTargets,
reminderMessages,
reminderRuns,
reminderRunTargets,
} from "@cmbot/db";
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit";
import { pgNotifyBot } from "@/lib/notify";
import { validateUpdateScheduledAt } from "@/lib/reminder-update";
import { resolveReminderName } from "@/lib/reminder-name";
async function rateLimit(key: string) {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
const r = await checkRateLimit(`${key}:${ip}`, { max: 30, windowSec: 10 });
if (r.limited) throw new Error("Too many requests");
}
export async function deleteReminderAction(formData: FormData): Promise<void> {
await rateLimit("delete-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const op = await getSeededOperator();
const reminder = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!reminder) return;
// Verify ownership via the account
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, op.id)),
});
if (!account) return;
// Cascading FKs (reminder_runs + reminder_targets + reminder_messages) clean up.
// pg-boss job for this reminder will fire and find the row gone (soft cancel).
await db.delete(reminders).where(eq(reminders.id, reminderId));
revalidatePath("/reminders" as any);
redirect("/reminders" as any);
}
/**
* Resolve and verify the reminder owned by the seeded operator. Returns
* null if the reminder doesn't exist or belongs to a different account.
*/
async function loadOwnedReminder(reminderId: string) {
const op = await getSeededOperator();
const reminder = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!reminder) return null;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, op.id)),
});
if (!account) return null;
return reminder;
}
/**
* Pause an active reminder. The pg-boss job stays armed (we don't have
* a hard cancel) but `fireReminder` exits early when status !== "active".
*/
export async function pauseReminderAction(formData: FormData): Promise<void> {
await rateLimit("pause-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const reminder = await loadOwnedReminder(reminderId);
if (!reminder) return;
if (reminder.status !== "active") return; // already not running
await db
.update(reminders)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(reminders.id, reminderId));
revalidatePath("/reminders" as any);
revalidatePath(`/reminders/${reminderId}` as any);
}
/**
* Restart a paused or ended reminder. For a one-off whose scheduledAt is
* in the past, push it to "now + 1 minute" so it fires soon. For a
* recurring reminder, compute the next occurrence from the RRULE.
* Either way the row flips back to `active` and the pg-boss job is
* re-armed.
*/
export async function restartReminderAction(formData: FormData): Promise<void> {
await rateLimit("restart-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const reminder = await loadOwnedReminder(reminderId);
if (!reminder) return;
let nextFire: Date | null = null;
const now = new Date();
if (reminder.scheduleKind === "recurring" && reminder.rrule) {
const { nextOccurrence } = await import("@cmbot/shared");
nextFire = nextOccurrence(reminder.rrule, reminder.timezone, now);
} else if (reminder.scheduledAt && reminder.scheduledAt.getTime() > Date.now() + 30_000) {
// The original time is still in the future and far enough away to
// be useful — keep it.
nextFire = reminder.scheduledAt;
} else {
nextFire = new Date(Date.now() + 60_000);
}
if (!nextFire) return;
await db
.update(reminders)
.set({
status: "active",
scheduledAt: nextFire,
updatedAt: now,
})
.where(eq(reminders.id, reminderId));
await pgNotifyBot({
type: "reminder.schedule",
reminderId,
scheduledAtIso: nextFire.toISOString(),
});
revalidatePath("/reminders" as any);
revalidatePath(`/reminders/${reminderId}` as any);
}
/**
* Duplicate a reminder. Creates a new reminder with the same account,
* groups, and message parts. The copy starts \`paused\` and inherits
* the source's scheduledAt / rrule unchanged — the user can edit the
* schedule from the detail page and Restart when ready.
*/
export async function duplicateReminderAction(formData: FormData): Promise<void> {
await rateLimit("duplicate-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const op = await getSeededOperator();
const source = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!source) return;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) =>
and(eq(a.id, source.accountId), eq(a.operatorId, op.id)),
});
if (!account) return;
const sourceTargets = await db.query.reminderTargets.findMany({
where: (t, { eq }) => eq(t.reminderId, reminderId),
});
const sourceMessages = await db.query.reminderMessages.findMany({
where: (m, { eq }) => eq(m.reminderId, reminderId),
});
const newId = await db.transaction(async (tx) => {
const [rem] = await tx
.insert(reminders)
.values({
accountId: source.accountId,
name: `${source.name} (copy)`.slice(0, 60),
scheduleKind: source.scheduleKind,
scheduledAt: source.scheduledAt,
rrule: source.rrule,
timezone: source.timezone,
// Start paused so the copy doesn't fire on top of the original
// — the user picks a new time / reactivates from the detail page.
status: "paused",
createdBy: op.id,
})
.returning({ id: reminders.id });
if (sourceTargets.length > 0) {
await tx.insert(reminderTargets).values(
sourceTargets.map((t) => ({
reminderId: rem!.id,
groupId: t.groupId,
position: t.position,
})),
);
}
if (sourceMessages.length > 0) {
await tx.insert(reminderMessages).values(
sourceMessages.map((m) => ({
reminderId: rem!.id,
position: m.position,
kind: m.kind,
textContent: m.textContent,
mediaId: m.mediaId,
})),
);
}
return rem!.id;
});
revalidatePath("/reminders");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/reminders/${newId}` as any);
}
// A single deliverable message part. See lib/reminder-messages.ts for
// the wire format the wizard URL uses.
const messagePartSchema = z
.object({
kind: z.enum(["text", "media"]),
textContent: z.string().nullable().optional(),
mediaId: z.string().uuid().nullable().optional(),
})
.refine(
(m) =>
m.kind === "text"
? Boolean(m.textContent && m.textContent.trim())
: Boolean(m.mediaId),
{ message: "Each message part needs text or a media file" },
);
const createReminderSchema = z
.object({
accountId: z.string().uuid(),
groupIds: z.array(z.string().uuid()),
// The new shape — caller passes one or more MessageParts in send order.
// Optional/nullable here so the legacy fallback below can be used by
// older URL bookmarks; the refine() guarantees we end up with at
// least one valid message either way.
messages: z.array(messagePartSchema).optional(),
// User-supplied label shown in the list / detail page header.
// Required: every reminder must carry a non-empty name. The
// resolver still clamps to REMINDER_NAME_MAX so the DB column
// never has to reject the row. The legacy auto-derive from the
// first message part is kept as a fallback ONLY for legacy
// bookmarked URLs (where the create form was submitted before
// the field was added) — new submits always carry a name.
name: z.string().trim().min(1, "Give the reminder a name").max(60),
// Legacy single-message fields. Still accepted so bookmarked
// /reminders/new URLs don't 400 after the migration. The action body
// collapses these into `messages` before doing any work.
text: z.string().nullable().optional(),
mediaId: z.string().uuid().nullable().optional(),
caption: z.string().nullable().optional(),
// `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets
// like `+08:00` (luxon's `toISO()` produces the offset form).
scheduledAtIso: z.string().datetime({ offset: true }),
rrule: z.string().nullable().optional(),
timezone: z.string().default(DEFAULT_TIMEZONE),
// Delivery window in the operator's timezone. End hour will gate
// the runtime fan-out in a later phase; start is documented but
// not yet enforced. Optional in the input shape for backward
// compatibility — the action body falls back to 6/18.
deliveryWindowStartHour: z.number().int().min(0).max(24).optional(),
deliveryWindowEndHour: z.number().int().min(0).max(24).optional(),
})
.refine(
(d) =>
(d.messages && d.messages.length > 0) ||
Boolean(d.text?.trim()) ||
Boolean(d.mediaId),
{
message: "Add a message or attach a file",
path: ["messages"],
},
)
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 24), {
message: "Delivery window start must be earlier than end",
path: ["deliveryWindowStartHour"],
});
/** Resolve the schema's union of new + legacy fields into a flat list. */
function resolveMessageParts(parsed: z.infer<typeof createReminderSchema>): Array<{
kind: "text" | "media";
textContent: string | null;
mediaId: string | null;
}> {
if (parsed.messages && parsed.messages.length > 0) {
return parsed.messages.map((m) => ({
kind: m.kind,
textContent: m.textContent ?? null,
mediaId: m.mediaId ?? null,
}));
}
// Legacy: fold (text, mediaId, caption) into one part.
if (parsed.mediaId) {
return [
{
kind: "media",
mediaId: parsed.mediaId,
textContent: parsed.caption?.trim() || parsed.text?.trim() || null,
},
];
}
return [
{
kind: "text",
textContent: parsed.text!,
mediaId: null,
},
];
}
export type CreateReminderResult =
| { ok: true; reminderId: string }
| { ok: false; error: string };
export async function createReminderAction(
input: z.infer<typeof createReminderSchema>,
): Promise<CreateReminderResult> {
await rateLimit("create-reminder");
const parsed = createReminderSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const {
accountId,
groupIds,
scheduledAtIso,
rrule,
timezone,
} = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
// operator hasn't ticked the optional "Pause sending by" checkbox;
// fall back to 24 here so legacy payloads / direct API calls don't
// accidentally enable the deadline at 6pm.
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
const parts = resolveMessageParts(parsed.data);
const op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) return { ok: false, error: "Account not yours" };
// Resolve the first-fire timestamp. Cron rules ignore the user-
// supplied date+time (the form sends a placeholder) and let the cron
// expression define when the reminder runs first.
let scheduledAt: Date;
if (rrule && isCronRule(rrule)) {
const minCheck = validateMinInterval(rrule, timezone);
if (!minCheck.ok) return { ok: false, error: minCheck.reason };
const firstFire = nextOccurrence(rrule, timezone, new Date());
if (!firstFire) {
return { ok: false, error: "Cron expression doesn't produce any future fire times" };
}
scheduledAt = firstFire;
} else {
scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate();
if (Number.isNaN(scheduledAt.getTime())) {
return { ok: false, error: "Invalid date" };
}
if (scheduledAt.getTime() <= Date.now()) {
return { ok: false, error: "Time is in the past" };
}
}
// Verify all groups belong to this account
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)),
});
if (groups.length !== groupIds.length) {
return { ok: false, error: "One or more groups don't belong to this account" };
}
// User-supplied name wins. If they didn't supply one, derive from
// the first text-bearing part (text body or caption). Falls back to
// the literal "Reminder" if every part is media-without-caption.
const reminderName = resolveReminderName(parsed.data.name, parts);
const reminderId = await db.transaction(async (tx) => {
const [rem] = await tx
.insert(reminders)
.values({
accountId,
name: reminderName,
scheduleKind: rrule ? "recurring" : "one_off",
scheduledAt,
rrule: rrule ?? null,
timezone,
deliveryWindowStartHour,
deliveryWindowEndHour,
status: "active",
createdBy: op.id,
})
.returning({ id: reminders.id });
if (groupIds.length > 0) {
await tx.insert(reminderTargets).values(
groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })),
);
}
await tx.insert(reminderMessages).values(
parts.map((p, position) => ({
reminderId: rem!.id,
position,
kind: p.kind,
textContent: p.textContent,
mediaId: p.mediaId,
})),
);
return rem!.id;
});
// Schedule via the bot's IPC consumer (Postgres NOTIFY)
await pgNotifyBot({
type: "reminder.schedule",
reminderId,
scheduledAtIso: scheduledAt.toISOString(),
});
return { ok: true, reminderId };
}
const updateReminderSchema = createReminderSchema.and(
z.object({ reminderId: z.string().uuid() }),
);
export type UpdateReminderResult =
| { ok: true; reminderId: string }
| { ok: false; error: string };
export async function updateReminderAction(
input: z.infer<typeof updateReminderSchema>,
): Promise<UpdateReminderResult> {
await rateLimit("update-reminder");
const parsed = updateReminderSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const {
reminderId,
accountId,
groupIds,
scheduledAtIso,
rrule,
timezone,
} = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
// operator hasn't ticked the optional "Pause sending by" checkbox;
// fall back to 24 here so legacy payloads / direct API calls don't
// accidentally enable the deadline at 6pm.
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
const parts = resolveMessageParts(parsed.data);
const op = await getSeededOperator();
// Verify the reminder exists, the operator owns its account, and the
// (possibly changed) target account is also theirs.
const existing = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!existing) return { ok: false, error: "Reminder not found" };
const ownerOfExisting = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, existing.accountId), eq(a.operatorId, op.id)),
});
if (!ownerOfExisting) return { ok: false, error: "Reminder not yours" };
const targetAccount = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!targetAccount) return { ok: false, error: "Account not yours" };
let scheduledAt: Date;
if (rrule && isCronRule(rrule)) {
const minCheck = validateMinInterval(rrule, timezone);
if (!minCheck.ok) return { ok: false, error: minCheck.reason };
const firstFire = nextOccurrence(rrule, timezone, new Date());
if (!firstFire) {
return { ok: false, error: "Cron expression doesn't produce any future fire times" };
}
scheduledAt = firstFire;
} else {
const validated = validateUpdateScheduledAt({
iso: scheduledAtIso,
timezone,
existingStatus: existing.status,
existingScheduledAt: existing.scheduledAt,
now: new Date(),
});
if (!validated.ok) return { ok: false, error: validated.error };
scheduledAt = validated.scheduledAt;
}
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)),
});
if (groups.length !== groupIds.length) {
return { ok: false, error: "One or more groups don't belong to this account" };
}
const reminderName = resolveReminderName(parsed.data.name, parts);
await db.transaction(async (tx) => {
await tx
.update(reminders)
.set({
accountId,
name: reminderName,
scheduleKind: rrule ? "recurring" : "one_off",
scheduledAt,
rrule: rrule ?? null,
timezone,
deliveryWindowStartHour,
deliveryWindowEndHour,
// Preserve the lifecycle status. Editing fields shouldn't
// implicitly re-activate a paused or ended reminder — the
// user can use the explicit Restart action for that.
status: existing.status,
updatedAt: new Date(),
})
.where(eq(reminders.id, reminderId));
// Replace targets and messages wholesale — simpler than diffing.
await tx.delete(reminderTargets).where(eq(reminderTargets.reminderId, reminderId));
if (groupIds.length > 0) {
await tx.insert(reminderTargets).values(
groupIds.map((groupId, position) => ({ reminderId, groupId, position })),
);
}
await tx.delete(reminderMessages).where(eq(reminderMessages.reminderId, reminderId));
await tx.insert(reminderMessages).values(
parts.map((p, position) => ({
reminderId,
position,
kind: p.kind,
textContent: p.textContent,
mediaId: p.mediaId,
})),
);
});
// Re-arm the pg-boss job at the new scheduled time. The handler uses
// singletonKey=reminder:<id> so this supersedes the prior arming.
await pgNotifyBot({
type: "reminder.schedule",
reminderId,
scheduledAtIso: scheduledAt.toISOString(),
});
revalidatePath("/reminders");
revalidatePath(`/reminders/${reminderId}`);
return { ok: true, reminderId };
}
// ---------------------------------------------------------------------------
// Resume / cancel a paused run
// ---------------------------------------------------------------------------
const runIdSchema = z.object({ runId: z.string().uuid() });
export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string };
/**
* Re-enqueue a paused reminder run. The bot picks it up, attaches to the
* existing run row, and only re-tries the rows still in `pending` state.
*
* Validates that the operator owns the underlying reminder + account
* pair and that the run is actually in 'paused' state — anything else
* is a no-op (so a stale UI button doesn't double-fire a run).
*/
export async function resumeReminderRunAction(input: {
runId: string;
}): Promise<ResumeReminderRunResult> {
const ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
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 ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
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 };
}