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:
yiekheng 2026-05-10 10:25:47 +08:00
parent 991ff5fb22
commit 5f1897daa5
10 changed files with 360 additions and 34 deletions

View File

@ -7,7 +7,7 @@ import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db"; import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
import { DEFAULT_TIMEZONE } 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";
import { checkRateLimit } from "@/lib/rate-limit"; import { checkRateLimit } from "@/lib/rate-limit";
@ -166,12 +166,26 @@ export async function createReminderAction(
}); });
if (!account) return { ok: false, error: "Account not yours" }; if (!account) return { ok: false, error: "Account not yours" };
const scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate(); // Resolve the first-fire timestamp. Cron rules ignore the user-
if (Number.isNaN(scheduledAt.getTime())) { // supplied date+time (the form sends a placeholder) and let the cron
return { ok: false, error: "Invalid date" }; // expression define when the reminder runs first.
} let scheduledAt: Date;
if (scheduledAt.getTime() <= Date.now()) { if (rrule && isCronRule(rrule)) {
return { ok: false, error: "Time is in the past" }; 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 // Verify all groups belong to this account
@ -278,12 +292,23 @@ export async function updateReminderAction(
}); });
if (!targetAccount) return { ok: false, error: "Account not yours" }; if (!targetAccount) return { ok: false, error: "Account not yours" };
const scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate(); let scheduledAt: Date;
if (Number.isNaN(scheduledAt.getTime())) { if (rrule && isCronRule(rrule)) {
return { ok: false, error: "Invalid date" }; const minCheck = validateMinInterval(rrule, timezone);
} if (!minCheck.ok) return { ok: false, error: minCheck.reason };
if (scheduledAt.getTime() <= Date.now()) { const firstFire = nextOccurrence(rrule, timezone, new Date());
return { ok: false, error: "Time is in the past" }; 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({ const groups = await db.query.whatsappGroups.findMany({

View File

@ -73,6 +73,12 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
return; return;
} }
setForceCustomOpen(false); 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)); onChange(presetToSpec(id, firstFire));
} }
@ -139,11 +145,55 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
{customExpanded && ( {customExpanded && (
<CustomPanel firstFire={firstFire} value={value} onChange={onChange} /> <CustomPanel firstFire={firstFire} value={value} onChange={onChange} />
)} )}
{activePreset === "cron" && (
<CronPanel value={value} onChange={onChange} />
)}
</div> </div>
</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 { interface CustomPanelProps {
firstFire: DateTime; firstFire: DateTime;
value: RecurrenceSpec; value: RecurrenceSpec;

View File

@ -63,22 +63,36 @@ export function EditWhenForm({
})(); })();
async function handleSave() { async function handleSave() {
const v = validateScheduledAt(date, time, timezone, Date.now()); let scheduledAtIso: string;
if (!v.ok) { let rrule: string | null;
const map = {
missing: "Pick both a date and a time.", if (spec.kind === "cron") {
invalid: "Invalid date or time.", if (!spec.cron || !spec.cron.trim()) {
past: "The first occurrence is in the past. Pick a future date and time.", setError("Enter a cron expression.");
} as const; return;
setError(map[v.reason]); }
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); setSubmitting(true);
setError(null); setError(null);
@ -90,7 +104,7 @@ export function EditWhenForm({
text, text,
mediaId, mediaId,
caption, caption,
scheduledAtIso: dt.toISO()!, scheduledAtIso,
rrule, rrule,
timezone, timezone,
}); });

View File

@ -59,6 +59,28 @@ export function WhenFormClient({
})(); })();
function handleContinue() { 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()); const v = validateScheduledAt(date, time, timezone, Date.now());
if (!v.ok) { if (!v.ok) {
const map = { const map = {

View File

@ -225,7 +225,7 @@ describe("preset shortcuts (Repeats picker)", () => {
).toBe("custom"); ).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); const items = presetDescriptors(FIRST);
expect(items.map((d) => d.id)).toEqual([ expect(items.map((d) => d.id)).toEqual([
"none", "none",
@ -236,6 +236,7 @@ describe("preset shortcuts (Repeats picker)", () => {
"monthly_same", "monthly_same",
"yearly_same", "yearly_same",
"custom", "custom",
"cron",
]); ]);
// Labels should be parameterised by firstFire. // Labels should be parameterised by firstFire.
expect(items.find((d) => d.id === "weekly_same")?.label).toBe("Every week on Wed"); 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( expect(items.find((d) => d.id === "yearly_same")?.label).toBe(
"Every year on May 13", "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 * * * *",
});
}); });
}); });

View File

@ -1,8 +1,10 @@
import { DateTime } from "luxon"; 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"; export type EndKind = "never" | "after" | "on";
const CRON_PREFIX = "CRON:";
const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const; 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 }> = [ 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 { export interface RecurrenceSpec {
kind: RecurrenceKind; kind: RecurrenceKind;
/** Every N units. Defaults to 1. Ignored for `none`. */ /** Every N units. Defaults to 1. Ignored for `none` and `cron`. */
interval: number; interval: number;
/** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */ /** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */
weeklyDays: number[]; weeklyDays: number[];
/** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */ /** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */
monthDay?: number; monthDay?: number;
/** Cron expression — only meaningful when kind === "cron". */
cron?: string;
/** End condition. */ /** End condition. */
end: end:
| { kind: "never" } | { kind: "never" }
@ -48,6 +52,9 @@ function clampInterval(n: number): number {
*/ */
export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null { export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null {
if (spec.kind === "none") return null; if (spec.kind === "none") return null;
if (spec.kind === "cron") {
return spec.cron ? `${CRON_PREFIX}${spec.cron.trim()}` : null;
}
const parts: string[] = []; const parts: string[] = [];
switch (spec.kind) { switch (spec.kind) {
@ -112,6 +119,9 @@ const FREQ_UNIT: Record<string, string> = {
*/ */
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string { export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
if (spec.kind === "none") return "One-off"; 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 interval = clampInterval(spec.interval);
const unit = FREQ_UNIT[spec.kind]!; 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 { export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec {
if (!rrule) return { ...DEFAULT_RECURRENCE }; 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 const tokens = rrule
.split(";") .split(";")
.map((t) => t.trim()) .map((t) => t.trim())
@ -206,7 +226,8 @@ export type PresetId =
| "weekly_same" | "weekly_same"
| "monthly_same" | "monthly_same"
| "yearly_same" | "yearly_same"
| "custom"; | "custom"
| "cron";
export interface PresetDescriptor { export interface PresetDescriptor {
id: PresetId; id: PresetId;
@ -249,6 +270,13 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec
// detailed spec the user already had. Return a sensible weekly // detailed spec the user already had. Return a sensible weekly
// default if the caller forgets to pass through. // default if the caller forgets to pass through.
return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] }; 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 { export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId {
if (spec.kind === "none") return "none"; if (spec.kind === "none") return "none";
if (spec.kind === "cron") return "cron";
const sameInterval = spec.interval === 1; const sameInterval = spec.interval === 1;
const noEnd = spec.end.kind === "never"; const noEnd = spec.end.kind === "never";
@ -283,6 +312,8 @@ export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId
return "custom"; return "custom";
case "yearly": case "yearly":
return "yearly_same"; return "yearly_same";
case "cron":
return "cron";
case "none": case "none":
return "none"; return "none";
} }
@ -319,5 +350,10 @@ export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] {
label: "Custom…", label: "Custom…",
hint: "Set interval, days, and end conditions yourself", hint: "Set interval, days, and end conditions yourself",
}, },
{
id: "cron",
label: "Cron expression…",
hint: "Full sec/min/hour/day/month/dow combinational power",
},
]; ];
} }

View File

@ -19,6 +19,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"cron-parser": "^5.5.0",
"rrule": "^2.8.1", "rrule": "^2.8.1",
"luxon": "^3.5.0" "luxon": "^3.5.0"
}, },

View File

@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("parseRRule", () => {
it("accepts a daily rule", () => { it("accepts a daily rule", () => {
@ -43,3 +52,70 @@ describe("MIN_INTERVAL_MS", () => {
expect(MIN_INTERVAL_MS).toBe(5 * 60 * 1000); 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");
});
});

View File

@ -11,6 +11,24 @@ const { RRule, rrulestr } = rrulePkg;
export const MIN_INTERVAL_MS = 5 * 60 * 1000; 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 { export function parseRRule(rule: string): RRuleType {
const parsed = rrulestr(rule); const parsed = rrulestr(rule);
if (!(parsed instanceof RRule)) { 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 { 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 parsed = parseRRule(rule);
const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate(); const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate();
const next = parsed.after(afterInZone, false); 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 type IntervalCheck = { ok: true } | { ok: false; reason: string };
export function validateMinInterval(rule: string, timezone: string): IntervalCheck { 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 parsed = parseRRule(rule);
const now = new Date(); const now = new Date();
const first = parsed.after(now, false); const first = parsed.after(now, false);
@ -44,3 +98,14 @@ export function validateMinInterval(rule: string, timezone: string): IntervalChe
} }
return { ok: true }; 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
View File

@ -212,6 +212,9 @@ importers:
packages/shared: packages/shared:
dependencies: dependencies:
cron-parser:
specifier: ^5.5.0
version: 5.5.0
luxon: luxon:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.7.2 version: 3.7.2