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",
|
"@whiskeysockets/baileys": "7.0.0-rc10",
|
||||||
"drizzle-orm": "^0.36.0",
|
"drizzle-orm": "^0.36.0",
|
||||||
"grammy": "^1.31.0",
|
"grammy": "^1.31.0",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
"pg-boss": "^12.18.2",
|
"pg-boss": "^12.18.2",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"pino-pretty": "^11.3.0",
|
"pino-pretty": "^11.3.0",
|
||||||
@ -25,6 +26,7 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^22.7.0",
|
"@types/node": "^22.7.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"tsx": "^4.19.0",
|
"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 DB = NodePgDatabase<typeof schema>;
|
||||||
|
|
||||||
|
export type Reminder = typeof schema.reminders.$inferSelect;
|
||||||
|
|
||||||
export function createClient(databaseUrl: string): { db: DB; pool: Pool } {
|
export function createClient(databaseUrl: string): { db: DB; pool: Pool } {
|
||||||
const pool = new Pool({ connectionString: databaseUrl });
|
const pool = new Pool({ connectionString: databaseUrl });
|
||||||
const db = drizzle(pool, { schema });
|
const db = drizzle(pool, { schema });
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@ -32,6 +32,9 @@ importers:
|
|||||||
grammy:
|
grammy:
|
||||||
specifier: ^1.31.0
|
specifier: ^1.31.0
|
||||||
version: 1.42.0
|
version: 1.42.0
|
||||||
|
luxon:
|
||||||
|
specifier: ^3.5.0
|
||||||
|
version: 3.7.2
|
||||||
pg-boss:
|
pg-boss:
|
||||||
specifier: ^12.18.2
|
specifier: ^12.18.2
|
||||||
version: 12.18.2
|
version: 12.18.2
|
||||||
@ -48,6 +51,9 @@ importers:
|
|||||||
specifier: ^3.23.8
|
specifier: ^3.23.8
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/luxon':
|
||||||
|
specifier: ^3.4.2
|
||||||
|
version: 3.7.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.7.0
|
specifier: ^22.7.0
|
||||||
version: 22.19.18
|
version: 22.19.18
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user