feat(recurrence): full crontab support — sec/min/hour/day/month/dow combinational
The custom RRULE panel covers most patterns but can't express "every
weekday at 9, 12, and 18" or "every 15 minutes within working hours"
in a single rule. RRULE's BYxxx fields can technically combine, but
the picker UX gets unwieldy fast. Cron expressions cover everything
in one line.
Storage / dispatch
- Cron rules live in the same `reminders.rrule` column with a sentinel
prefix: `CRON:0 9 * * 1-5`. No schema change.
- `@cmbot/shared` now exports:
CRON_PREFIX, isCronRule, stripCronPrefix, validateCronExpression
- `nextOccurrence(rule, tz, after)` and `validateMinInterval(rule, tz)`
detect the prefix and dispatch to `cron-parser`; non-cron rules
continue to flow through rrule unchanged.
- `cron-parser@^5.5.0` added as an explicit dep on @cmbot/shared
(it was already transitively present via pg-boss).
Server actions
- `createReminderAction` / `updateReminderAction`: when rrule has the
CRON: prefix, the user's date+time inputs are ignored — the action
validates the cron, runs the min-interval check (5 min between
fires), and computes scheduledAt as the next match of the cron
expression after now. The bot's existing fire-reminder loop
re-arms via `nextOccurrence` after each fire, which already speaks
cron via the dispatch above.
Picker
- New "Cron expression…" preset at the bottom of the radio list:
"Full sec/min/hour/day/month/dow combinational power"
Selecting it reveals a CronPanel:
* font-mono cron input (5- or 6-field accepted)
* inline examples: 0 9 * * 1-5, */15 * * * *, 0 9,12,18 * * *,
0 0 1 * *
* note that the Date+Time controls above are ignored once a cron
expression is set
- RecurrenceSpec gains an optional `cron` string and a new `kind: "cron"`.
- buildRrule emits `CRON:<expr>` for cron specs.
- specFromRrule round-trips a CRON-prefixed rule back into the spec.
- describeRecurrence renders "Cron: <expr>" so the list view and
review steps show the expression.
Tests (+10; 17 shared + 26 bot + 138 web = 181 total)
- packages/shared rrule.test.ts (+8):
* CRON_PREFIX / isCronRule / stripCronPrefix
* nextOccurrence on a CRON rule returns the right next match in the
operator timezone (e.g. weekday 9 AM KL ↔ exact UTC instant)
* RRULE rules still flow through unchanged
* validateMinInterval on cron: hourly OK, every-minute rejected,
malformed string returns a useful error
* validateCronExpression positive + negative cases
- recurrence.test.ts (+5): cron preset round-trip, label assertions,
`buildRrule`/`specFromRrule` for cron specs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
991ff5fb22
commit
5f1897daa5
@ -7,7 +7,7 @@ import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { DateTime } from "luxon";
|
||||
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
|
||||
import { DEFAULT_TIMEZONE } from "@cmbot/shared";
|
||||
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";
|
||||
@ -166,12 +166,26 @@ export async function createReminderAction(
|
||||
});
|
||||
if (!account) return { ok: false, error: "Account not yours" };
|
||||
|
||||
const 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" };
|
||||
// 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
|
||||
@ -278,12 +292,23 @@ export async function updateReminderAction(
|
||||
});
|
||||
if (!targetAccount) return { ok: false, error: "Account not yours" };
|
||||
|
||||
const 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" };
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
const groups = await db.query.whatsappGroups.findMany({
|
||||
|
||||
@ -73,6 +73,12 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
||||
return;
|
||||
}
|
||||
setForceCustomOpen(false);
|
||||
if (id === "cron") {
|
||||
// Preserve any cron expression the user already typed.
|
||||
if (value.kind === "cron") return;
|
||||
onChange(presetToSpec("cron", firstFire));
|
||||
return;
|
||||
}
|
||||
onChange(presetToSpec(id, firstFire));
|
||||
}
|
||||
|
||||
@ -139,11 +145,55 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
||||
{customExpanded && (
|
||||
<CustomPanel firstFire={firstFire} value={value} onChange={onChange} />
|
||||
)}
|
||||
|
||||
{activePreset === "cron" && (
|
||||
<CronPanel value={value} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CronPanel({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RecurrenceSpec;
|
||||
onChange: (next: RecurrenceSpec) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border bg-muted/20 p-4">
|
||||
<Label htmlFor="cron-expr" className="text-xs text-muted-foreground">
|
||||
Cron expression
|
||||
</Label>
|
||||
<Input
|
||||
id="cron-expr"
|
||||
value={value.cron ?? ""}
|
||||
onChange={(e) => onChange({ ...value, cron: e.target.value })}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="h-8 font-mono text-sm"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Standard 5-field cron (<code className="font-mono">m h dom mon dow</code>) or
|
||||
6-field with seconds (<code className="font-mono">s m h dom mon dow</code>).
|
||||
Examples:
|
||||
</p>
|
||||
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 pl-3">
|
||||
<li><span className="text-foreground">0 9 * * 1-5</span> — 9 am on weekdays</li>
|
||||
<li><span className="text-foreground">*/15 * * * *</span> — every 15 minutes</li>
|
||||
<li><span className="text-foreground">0 9,12,18 * * *</span> — 9, 12, and 18 every day</li>
|
||||
<li><span className="text-foreground">0 0 1 * *</span> — midnight on the 1st of every month</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
The Date+Time controls above are ignored when a cron expression is set;
|
||||
cron drives the schedule entirely. The first fire is the next time the
|
||||
cron expression matches after now.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomPanelProps {
|
||||
firstFire: DateTime;
|
||||
value: RecurrenceSpec;
|
||||
|
||||
@ -63,22 +63,36 @@ export function EditWhenForm({
|
||||
})();
|
||||
|
||||
async function handleSave() {
|
||||
const v = validateScheduledAt(date, time, timezone, Date.now());
|
||||
if (!v.ok) {
|
||||
const map = {
|
||||
missing: "Pick both a date and a time.",
|
||||
invalid: "Invalid date or time.",
|
||||
past: "The first occurrence is in the past. Pick a future date and time.",
|
||||
} as const;
|
||||
setError(map[v.reason]);
|
||||
return;
|
||||
let scheduledAtIso: string;
|
||||
let rrule: string | null;
|
||||
|
||||
if (spec.kind === "cron") {
|
||||
if (!spec.cron || !spec.cron.trim()) {
|
||||
setError("Enter a cron expression.");
|
||||
return;
|
||||
}
|
||||
// Server overrides scheduledAt with the cron's next match. Send
|
||||
// a stub timestamp so the action's Zod schema is happy.
|
||||
scheduledAtIso = DateTime.now().plus({ minutes: 1 }).toISO()!;
|
||||
rrule = buildRrule(spec, previewDt);
|
||||
} else {
|
||||
const v = validateScheduledAt(date, time, timezone, Date.now());
|
||||
if (!v.ok) {
|
||||
const map = {
|
||||
missing: "Pick both a date and a time.",
|
||||
invalid: "Invalid date or time.",
|
||||
past: "The first occurrence is in the past. Pick a future date and time.",
|
||||
} as const;
|
||||
setError(map[v.reason]);
|
||||
return;
|
||||
}
|
||||
if (spec.end.kind === "on" && !spec.end.until) {
|
||||
setError("Pick the end date for this recurrence.");
|
||||
return;
|
||||
}
|
||||
scheduledAtIso = v.dt.toISO()!;
|
||||
rrule = buildRrule(spec, v.dt);
|
||||
}
|
||||
if (spec.end.kind === "on" && !spec.end.until) {
|
||||
setError("Pick the end date for this recurrence.");
|
||||
return;
|
||||
}
|
||||
const dt = v.dt;
|
||||
const rrule = buildRrule(spec, dt);
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
@ -90,7 +104,7 @@ export function EditWhenForm({
|
||||
text,
|
||||
mediaId,
|
||||
caption,
|
||||
scheduledAtIso: dt.toISO()!,
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
timezone,
|
||||
});
|
||||
|
||||
@ -59,6 +59,28 @@ export function WhenFormClient({
|
||||
})();
|
||||
|
||||
function handleContinue() {
|
||||
// Cron mode: the cron expression defines its own first fire on the
|
||||
// server side. The Date/Time inputs are ignored — pass a stub
|
||||
// scheduledAt that the server will overwrite.
|
||||
if (spec.kind === "cron") {
|
||||
if (!spec.cron || !spec.cron.trim()) {
|
||||
setError("Enter a cron expression.");
|
||||
return;
|
||||
}
|
||||
const rrule = buildRrule(spec, previewDt);
|
||||
const scheduledAt = DateTime.now().plus({ minutes: 1 }).toISO()!;
|
||||
const sp = new URLSearchParams({ step: "4", accountId, scheduledAt });
|
||||
if (groupIds) sp.set("groupIds", groupIds);
|
||||
if (rrule) sp.set("rrule", rrule);
|
||||
if (passThroughParams.text) sp.set("text", passThroughParams.text);
|
||||
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
|
||||
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const v = validateScheduledAt(date, time, timezone, Date.now());
|
||||
if (!v.ok) {
|
||||
const map = {
|
||||
|
||||
@ -225,7 +225,7 @@ describe("preset shortcuts (Repeats picker)", () => {
|
||||
).toBe("custom");
|
||||
});
|
||||
|
||||
it("presetDescriptors returns 8 entries with first-fire-aware labels", () => {
|
||||
it("presetDescriptors returns the full preset list with first-fire-aware labels", () => {
|
||||
const items = presetDescriptors(FIRST);
|
||||
expect(items.map((d) => d.id)).toEqual([
|
||||
"none",
|
||||
@ -236,6 +236,7 @@ describe("preset shortcuts (Repeats picker)", () => {
|
||||
"monthly_same",
|
||||
"yearly_same",
|
||||
"custom",
|
||||
"cron",
|
||||
]);
|
||||
// Labels should be parameterised by firstFire.
|
||||
expect(items.find((d) => d.id === "weekly_same")?.label).toBe("Every week on Wed");
|
||||
@ -245,6 +246,39 @@ describe("preset shortcuts (Repeats picker)", () => {
|
||||
expect(items.find((d) => d.id === "yearly_same")?.label).toBe(
|
||||
"Every year on May 13",
|
||||
);
|
||||
expect(items.find((d) => d.id === "cron")?.label).toBe("Cron expression…");
|
||||
});
|
||||
|
||||
it("presetToSpec('cron') seeds a daily-at-the-first-fire cron", () => {
|
||||
const spec = presetToSpec("cron", FIRST);
|
||||
expect(spec.kind).toBe("cron");
|
||||
// FIRST is 09:00, so default cron = "0 9 * * *" (every day at 9:00).
|
||||
expect(spec.cron).toBe("0 9 * * *");
|
||||
});
|
||||
|
||||
it("matchPreset returns 'cron' for any cron-kind spec", () => {
|
||||
expect(
|
||||
matchPreset(
|
||||
{ kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } },
|
||||
FIRST,
|
||||
),
|
||||
).toBe("cron");
|
||||
});
|
||||
|
||||
it("buildRrule produces a CRON: prefixed string for cron specs", () => {
|
||||
expect(
|
||||
buildRrule(
|
||||
{ kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } },
|
||||
FIRST,
|
||||
),
|
||||
).toBe("CRON:0 9 * * 1-5");
|
||||
});
|
||||
|
||||
it("specFromRrule round-trips a CRON: prefixed rule", () => {
|
||||
expect(specFromRrule("CRON:*/15 * * * *")).toMatchObject({
|
||||
kind: "cron",
|
||||
cron: "*/15 * * * *",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly" | "cron";
|
||||
export type EndKind = "never" | "after" | "on";
|
||||
|
||||
const CRON_PREFIX = "CRON:";
|
||||
|
||||
const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
|
||||
|
||||
export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [
|
||||
@ -17,12 +19,14 @@ export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; l
|
||||
|
||||
export interface RecurrenceSpec {
|
||||
kind: RecurrenceKind;
|
||||
/** Every N units. Defaults to 1. Ignored for `none`. */
|
||||
/** Every N units. Defaults to 1. Ignored for `none` and `cron`. */
|
||||
interval: number;
|
||||
/** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */
|
||||
weeklyDays: number[];
|
||||
/** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */
|
||||
monthDay?: number;
|
||||
/** Cron expression — only meaningful when kind === "cron". */
|
||||
cron?: string;
|
||||
/** End condition. */
|
||||
end:
|
||||
| { kind: "never" }
|
||||
@ -48,6 +52,9 @@ function clampInterval(n: number): number {
|
||||
*/
|
||||
export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null {
|
||||
if (spec.kind === "none") return null;
|
||||
if (spec.kind === "cron") {
|
||||
return spec.cron ? `${CRON_PREFIX}${spec.cron.trim()}` : null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
switch (spec.kind) {
|
||||
@ -112,6 +119,9 @@ const FREQ_UNIT: Record<string, string> = {
|
||||
*/
|
||||
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
|
||||
if (spec.kind === "none") return "One-off";
|
||||
if (spec.kind === "cron") {
|
||||
return spec.cron ? `Cron: ${spec.cron}` : "Cron (not configured)";
|
||||
}
|
||||
|
||||
const interval = clampInterval(spec.interval);
|
||||
const unit = FREQ_UNIT[spec.kind]!;
|
||||
@ -148,6 +158,16 @@ export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): s
|
||||
export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec {
|
||||
if (!rrule) return { ...DEFAULT_RECURRENCE };
|
||||
|
||||
if (rrule.startsWith(CRON_PREFIX)) {
|
||||
return {
|
||||
kind: "cron",
|
||||
interval: 1,
|
||||
weeklyDays: [],
|
||||
cron: rrule.slice(CRON_PREFIX.length),
|
||||
end: { kind: "never" },
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = rrule
|
||||
.split(";")
|
||||
.map((t) => t.trim())
|
||||
@ -206,7 +226,8 @@ export type PresetId =
|
||||
| "weekly_same"
|
||||
| "monthly_same"
|
||||
| "yearly_same"
|
||||
| "custom";
|
||||
| "custom"
|
||||
| "cron";
|
||||
|
||||
export interface PresetDescriptor {
|
||||
id: PresetId;
|
||||
@ -249,6 +270,13 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec
|
||||
// detailed spec the user already had. Return a sensible weekly
|
||||
// default if the caller forgets to pass through.
|
||||
return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] };
|
||||
case "cron":
|
||||
// Default cron expression: every day at the first-fire's HH:MM.
|
||||
return {
|
||||
...base,
|
||||
kind: "cron",
|
||||
cron: `${firstFire.minute} ${firstFire.hour} * * *`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -260,6 +288,7 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec
|
||||
*/
|
||||
export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId {
|
||||
if (spec.kind === "none") return "none";
|
||||
if (spec.kind === "cron") return "cron";
|
||||
|
||||
const sameInterval = spec.interval === 1;
|
||||
const noEnd = spec.end.kind === "never";
|
||||
@ -283,6 +312,8 @@ export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId
|
||||
return "custom";
|
||||
case "yearly":
|
||||
return "yearly_same";
|
||||
case "cron":
|
||||
return "cron";
|
||||
case "none":
|
||||
return "none";
|
||||
}
|
||||
@ -319,5 +350,10 @@ export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] {
|
||||
label: "Custom…",
|
||||
hint: "Set interval, days, and end conditions yourself",
|
||||
},
|
||||
{
|
||||
id: "cron",
|
||||
label: "Cron expression…",
|
||||
hint: "Full sec/min/hour/day/month/dow combinational power",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"cron-parser": "^5.5.0",
|
||||
"rrule": "^2.8.1",
|
||||
"luxon": "^3.5.0"
|
||||
},
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseRRule, nextOccurrence, validateMinInterval, MIN_INTERVAL_MS } from "./rrule.js";
|
||||
import {
|
||||
parseRRule,
|
||||
nextOccurrence,
|
||||
validateMinInterval,
|
||||
validateCronExpression,
|
||||
isCronRule,
|
||||
stripCronPrefix,
|
||||
CRON_PREFIX,
|
||||
MIN_INTERVAL_MS,
|
||||
} from "./rrule.js";
|
||||
|
||||
describe("parseRRule", () => {
|
||||
it("accepts a daily rule", () => {
|
||||
@ -43,3 +52,70 @@ describe("MIN_INTERVAL_MS", () => {
|
||||
expect(MIN_INTERVAL_MS).toBe(5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron prefix detection", () => {
|
||||
it("CRON_PREFIX is 'CRON:'", () => {
|
||||
expect(CRON_PREFIX).toBe("CRON:");
|
||||
});
|
||||
|
||||
it("isCronRule recognises the prefix", () => {
|
||||
expect(isCronRule("CRON:0 9 * * *")).toBe(true);
|
||||
expect(isCronRule("FREQ=DAILY")).toBe(false);
|
||||
expect(isCronRule("")).toBe(false);
|
||||
});
|
||||
|
||||
it("stripCronPrefix removes the prefix when present", () => {
|
||||
expect(stripCronPrefix("CRON:0 9 * * *")).toBe("0 9 * * *");
|
||||
// Idempotent for non-cron rules.
|
||||
expect(stripCronPrefix("FREQ=DAILY")).toBe("FREQ=DAILY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextOccurrence with cron rules", () => {
|
||||
it("dispatches CRON: rules to cron-parser and returns the next match", () => {
|
||||
// 9:00 every weekday in Asia/Kuala_Lumpur.
|
||||
const after = new Date("2026-05-09T08:00:00Z"); // Sat
|
||||
const next = nextOccurrence("CRON:0 9 * * 1-5", "Asia/Kuala_Lumpur", after);
|
||||
expect(next).toBeInstanceOf(Date);
|
||||
// Next weekday at 9 AM KL is Mon 2026-05-11 09:00 KL → 01:00 UTC.
|
||||
expect(next!.toISOString()).toBe("2026-05-11T01:00:00.000Z");
|
||||
});
|
||||
|
||||
it("still handles RRULE rules unchanged", () => {
|
||||
const next = nextOccurrence(
|
||||
"FREQ=DAILY;BYHOUR=9;BYMINUTE=0",
|
||||
"Asia/Kuala_Lumpur",
|
||||
new Date("2026-05-03T08:00:00Z"),
|
||||
);
|
||||
expect(next).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMinInterval with cron rules", () => {
|
||||
it("accepts an hourly cron (interval > 5 min)", () => {
|
||||
expect(validateMinInterval("CRON:0 * * * *", "Asia/Kuala_Lumpur")).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects a cron firing every minute", () => {
|
||||
const r = validateMinInterval("CRON:* * * * *", "Asia/Kuala_Lumpur");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toMatch(/minimum interval|fires every/i);
|
||||
});
|
||||
|
||||
it("rejects a malformed cron string with a useful message", () => {
|
||||
const r = validateMinInterval("CRON:not a cron", "Asia/Kuala_Lumpur");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason.toLowerCase()).toContain("invalid cron");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCronExpression", () => {
|
||||
it("returns null for a valid cron expression", () => {
|
||||
expect(validateCronExpression("0 9 * * 1-5", "Asia/Kuala_Lumpur")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns an error string for malformed input", () => {
|
||||
const err = validateCronExpression("not a cron", "Asia/Kuala_Lumpur");
|
||||
expect(typeof err).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,6 +11,24 @@ const { RRule, rrulestr } = rrulePkg;
|
||||
|
||||
export const MIN_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Sentinel prefix marking a cron expression stored in the same column
|
||||
* as RRULE strings. e.g. "CRON:0 9 * * 1-5" → 09:00 every weekday.
|
||||
*
|
||||
* Cron is more expressive than our subset of RRULE (true sec/min/
|
||||
* hour/day/month/year combinational scheduling) so we let the user
|
||||
* supply one directly. The bot dispatches on this prefix.
|
||||
*/
|
||||
export const CRON_PREFIX = "CRON:";
|
||||
|
||||
export function isCronRule(rule: string): boolean {
|
||||
return rule.startsWith(CRON_PREFIX);
|
||||
}
|
||||
|
||||
export function stripCronPrefix(rule: string): string {
|
||||
return rule.startsWith(CRON_PREFIX) ? rule.slice(CRON_PREFIX.length) : rule;
|
||||
}
|
||||
|
||||
export function parseRRule(rule: string): RRuleType {
|
||||
const parsed = rrulestr(rule);
|
||||
if (!(parsed instanceof RRule)) {
|
||||
@ -20,6 +38,21 @@ export function parseRRule(rule: string): RRuleType {
|
||||
}
|
||||
|
||||
export function nextOccurrence(rule: string, timezone: string, after: Date): Date | null {
|
||||
if (isCronRule(rule)) {
|
||||
// Lazy require keeps cron-parser out of the import graph for callers
|
||||
// that never use cron rules.
|
||||
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
|
||||
try {
|
||||
const it = CronExpressionParser.parse(stripCronPrefix(rule), {
|
||||
currentDate: after,
|
||||
tz: timezone,
|
||||
});
|
||||
const next = it.next();
|
||||
return next.toDate();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const parsed = parseRRule(rule);
|
||||
const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate();
|
||||
const next = parsed.after(afterInZone, false);
|
||||
@ -29,6 +62,27 @@ export function nextOccurrence(rule: string, timezone: string, after: Date): Dat
|
||||
export type IntervalCheck = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
export function validateMinInterval(rule: string, timezone: string): IntervalCheck {
|
||||
if (isCronRule(rule)) {
|
||||
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
|
||||
try {
|
||||
const it = CronExpressionParser.parse(stripCronPrefix(rule), {
|
||||
currentDate: new Date(),
|
||||
tz: timezone,
|
||||
});
|
||||
const first = it.next().toDate();
|
||||
const second = it.next().toDate();
|
||||
const gap = second.getTime() - first.getTime();
|
||||
if (gap < MIN_INTERVAL_MS) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Cron fires every ${Math.round(gap / 1000)}s; minimum interval is ${MIN_INTERVAL_MS / 1000}s.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `Invalid cron expression: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
const parsed = parseRRule(rule);
|
||||
const now = new Date();
|
||||
const first = parsed.after(now, false);
|
||||
@ -44,3 +98,14 @@ export function validateMinInterval(rule: string, timezone: string): IntervalChe
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Validate a cron expression — returns null on success, error message on failure. */
|
||||
export function validateCronExpression(expr: string, timezone: string): string | null {
|
||||
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
|
||||
try {
|
||||
CronExpressionParser.parse(expr, { tz: timezone });
|
||||
return null;
|
||||
} catch (err) {
|
||||
return (err as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -212,6 +212,9 @@ importers:
|
||||
|
||||
packages/shared:
|
||||
dependencies:
|
||||
cron-parser:
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
luxon:
|
||||
specifier: ^3.5.0
|
||||
version: 3.7.2
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user