feat(reminders): add time-parsing + CRUD helpers

This commit is contained in:
yiekheng 2026-05-09 17:22:00 +08:00
parent 113adc7edf
commit 1aef3e969c
6 changed files with 198 additions and 0 deletions

View File

@ -18,6 +18,7 @@
"@whiskeysockets/baileys": "7.0.0-rc10",
"drizzle-orm": "^0.36.0",
"grammy": "^1.31.0",
"luxon": "^3.5.0",
"pg-boss": "^12.18.2",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
@ -25,6 +26,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@types/luxon": "^3.4.2",
"@types/node": "^22.7.0",
"@types/qrcode": "^1.5.5",
"tsx": "^4.19.0",

View File

@ -0,0 +1,109 @@
import { eq, sql } from "drizzle-orm";
import {
reminders,
reminderMessages,
reminderTargets,
type Reminder,
} from "@cmbot/db";
import { db } from "../db.js";
import { DEFAULT_TIMEZONE } from "@cmbot/shared";
export type CreateReminderInput = {
accountId: string;
groupId: string;
name: string;
scheduledAt: Date;
text?: string | null;
mediaId?: string | null;
caption?: string | null;
createdBy: string;
timezone?: string;
};
export type ReminderWithDetails = Reminder & {
targets: { groupId: string }[];
messages: { id: string; position: number; kind: string; textContent: string | null; mediaId: string | null }[];
};
export async function createReminder(input: CreateReminderInput): Promise<string> {
return await db.transaction(async (tx) => {
const [rem] = await tx
.insert(reminders)
.values({
accountId: input.accountId,
name: input.name,
scheduleKind: "one_off",
scheduledAt: input.scheduledAt,
timezone: input.timezone ?? DEFAULT_TIMEZONE,
status: "active",
createdBy: input.createdBy,
})
.returning({ id: reminders.id });
await tx.insert(reminderTargets).values({
reminderId: rem!.id,
groupId: input.groupId,
position: 0,
});
let position = 0;
if (input.text && !input.mediaId) {
await tx.insert(reminderMessages).values({
reminderId: rem!.id,
position: position++,
kind: "text",
textContent: input.text,
mediaId: null,
});
} else if (input.mediaId) {
await tx.insert(reminderMessages).values({
reminderId: rem!.id,
position: position++,
kind: "media",
textContent: input.caption ?? input.text ?? null,
mediaId: input.mediaId,
});
}
return rem!.id;
});
}
export async function getReminderWithDetails(id: string): Promise<ReminderWithDetails | null> {
const rem = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, id),
});
if (!rem) return null;
const targets = await db.query.reminderTargets.findMany({
where: (t, { eq }) => eq(t.reminderId, id),
});
const messages = await db.query.reminderMessages.findMany({
where: (m, { eq }) => eq(m.reminderId, id),
orderBy: (m, { asc }) => [asc(m.position)],
});
return { ...rem, targets, messages };
}
export async function listRemindersForOperator(
operatorId: string,
limit = 50,
): Promise<(Reminder & { accountLabel: string; groupCount: number })[]> {
// Use parameterized SQL via drizzle's sql tag for safety. operatorId is a
// server-controlled UUID, but parameterizing is the right habit anyway.
const rows = await db.execute(sql`
SELECT
r.*,
wa.label as account_label,
(SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count
FROM reminders r
JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
ORDER BY r.scheduled_at DESC NULLS LAST, r.created_at DESC
LIMIT ${limit}
`);
return rows.rows as never;
}
export async function deleteReminder(id: string): Promise<void> {
await db.delete(reminders).where(eq(reminders.id, id));
}

View File

@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { quickToDate, parseFreeText } from "./time-parsing.js";
describe("quickToDate", () => {
it("in_1h returns ~1h ahead", () => {
const d = quickToDate("in_1h");
const diffMs = d.getTime() - Date.now();
expect(diffMs).toBeGreaterThan(55 * 60 * 1000);
expect(diffMs).toBeLessThan(65 * 60 * 1000);
});
it("now returns ~30s ahead", () => {
const d = quickToDate("now");
expect(d.getTime() - Date.now()).toBeGreaterThan(20 * 1000);
});
});
describe("parseFreeText", () => {
it("accepts YYYY-MM-DD HH:MM", () => {
const r = parseFreeText("2099-12-31 23:59");
expect(r.ok).toBe(true);
});
it("rejects past times", () => {
const r = parseFreeText("2020-01-01 00:00");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/past/i);
});
it("rejects garbage", () => {
const r = parseFreeText("not a date");
expect(r.ok).toBe(false);
});
});

View File

@ -0,0 +1,48 @@
import { DateTime } from "luxon";
import { DEFAULT_TIMEZONE } from "@cmbot/shared";
export type Quick = "now" | "in_1h" | "in_3h" | "tomorrow_9am" | "next_mon_9am";
export function quickToDate(quick: Quick, timezone: string = DEFAULT_TIMEZONE): Date {
const now = DateTime.now().setZone(timezone);
switch (quick) {
case "now":
return now.plus({ seconds: 30 }).toJSDate();
case "in_1h":
return now.plus({ hours: 1 }).toJSDate();
case "in_3h":
return now.plus({ hours: 3 }).toJSDate();
case "tomorrow_9am":
return now.plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate();
case "next_mon_9am": {
const dow = now.weekday; // 1 = Mon
const daysUntilMon = ((1 - dow + 7) % 7) || 7;
return now.plus({ days: daysUntilMon }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate();
}
}
}
export type ParseResult = { ok: true; date: Date } | { ok: false; reason: string };
const FORMATS = [
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm",
"dd/MM/yyyy HH:mm",
"dd-MM-yyyy HH:mm",
];
export function parseFreeText(input: string, timezone: string = DEFAULT_TIMEZONE): ParseResult {
const trimmed = input.trim();
for (const fmt of FORMATS) {
const dt = DateTime.fromFormat(trimmed, fmt, { zone: timezone });
if (dt.isValid) {
const jsDate = dt.toJSDate();
if (jsDate.getTime() <= Date.now()) {
return { ok: false, reason: "Time is in the past" };
}
return { ok: true, date: jsDate };
}
}
return { ok: false, reason: "Couldn't parse — try YYYY-MM-DD HH:MM (e.g. 2026-05-15 09:00)" };
}

View File

@ -6,6 +6,8 @@ export * from "./schema.js";
export type DB = NodePgDatabase<typeof schema>;
export type Reminder = typeof schema.reminders.$inferSelect;
export function createClient(databaseUrl: string): { db: DB; pool: Pool } {
const pool = new Pool({ connectionString: databaseUrl });
const db = drizzle(pool, { schema });

6
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
grammy:
specifier: ^1.31.0
version: 1.42.0
luxon:
specifier: ^3.5.0
version: 3.7.2
pg-boss:
specifier: ^12.18.2
version: 12.18.2
@ -48,6 +51,9 @@ importers:
specifier: ^3.23.8
version: 3.25.76
devDependencies:
'@types/luxon':
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^22.7.0
version: 22.19.18