From 1aef3e969c555338b8b4737473c2c3812e5c0f01 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 17:22:00 +0800 Subject: [PATCH] feat(reminders): add time-parsing + CRUD helpers --- apps/bot/package.json | 2 + apps/bot/src/reminders/crud.ts | 109 ++++++++++++++++++++ apps/bot/src/reminders/time-parsing.test.ts | 31 ++++++ apps/bot/src/reminders/time-parsing.ts | 48 +++++++++ packages/db/src/index.ts | 2 + pnpm-lock.yaml | 6 ++ 6 files changed, 198 insertions(+) create mode 100644 apps/bot/src/reminders/crud.ts create mode 100644 apps/bot/src/reminders/time-parsing.test.ts create mode 100644 apps/bot/src/reminders/time-parsing.ts diff --git a/apps/bot/package.json b/apps/bot/package.json index ebe490c..4c38790 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -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", diff --git a/apps/bot/src/reminders/crud.ts b/apps/bot/src/reminders/crud.ts new file mode 100644 index 0000000..216ddb7 --- /dev/null +++ b/apps/bot/src/reminders/crud.ts @@ -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 { + 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 { + 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 { + await db.delete(reminders).where(eq(reminders.id, id)); +} diff --git a/apps/bot/src/reminders/time-parsing.test.ts b/apps/bot/src/reminders/time-parsing.test.ts new file mode 100644 index 0000000..53d31fd --- /dev/null +++ b/apps/bot/src/reminders/time-parsing.test.ts @@ -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); + }); +}); diff --git a/apps/bot/src/reminders/time-parsing.ts b/apps/bot/src/reminders/time-parsing.ts new file mode 100644 index 0000000..34719b9 --- /dev/null +++ b/apps/bot/src/reminders/time-parsing.ts @@ -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)" }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 248f95b..f36c389 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -6,6 +6,8 @@ export * from "./schema.js"; export type DB = NodePgDatabase; +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 }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6a1a3e..29108e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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