feat(reminders): add time-parsing + CRUD helpers
This commit is contained in:
parent
113adc7edf
commit
1aef3e969c
@ -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",
|
||||
|
||||
109
apps/bot/src/reminders/crud.ts
Normal file
109
apps/bot/src/reminders/crud.ts
Normal 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));
|
||||
}
|
||||
31
apps/bot/src/reminders/time-parsing.test.ts
Normal file
31
apps/bot/src/reminders/time-parsing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
apps/bot/src/reminders/time-parsing.ts
Normal file
48
apps/bot/src/reminders/time-parsing.ts
Normal 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)" };
|
||||
}
|
||||
@ -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
6
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user