From 3d470069d360a58c16cec94c17ebb35ff7592a81 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 23:48:35 +0800 Subject: [PATCH] feat(web): create reminder + media upload server actions Co-Authored-By: Claude Sonnet 4.6 --- apps/web/package.json | 2 + apps/web/src/actions/media.ts | 63 +++++++++++++++++++ apps/web/src/actions/reminders.ts | 100 +++++++++++++++++++++++++++++- pnpm-lock.yaml | 6 ++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/actions/media.ts diff --git a/apps/web/package.json b/apps/web/package.json index c7947be..8aa8adc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,11 +15,13 @@ "@cmbot/db": "workspace:*", "@cmbot/shared": "workspace:*", "@hookform/resolvers": "^5.2.2", + "@types/luxon": "^3.4.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.36.0", "geist": "^1.7.0", "lucide-react": "^1.14.0", + "luxon": "^3.5.0", "next": "^16.0.0", "next-themes": "^0.4.6", "pg": "^8.13.0", diff --git a/apps/web/src/actions/media.ts b/apps/web/src/actions/media.ts new file mode 100644 index 0000000..d64a746 --- /dev/null +++ b/apps/web/src/actions/media.ts @@ -0,0 +1,63 @@ +"use server"; + +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { createHash } from "node:crypto"; +import { headers } from "next/headers"; +import { mediaFiles } from "@cmbot/db"; +import { newMediaPath, absoluteMediaPath } from "@cmbot/shared"; +import { db } from "@/lib/db"; +import { env } from "@/env"; +import { getSeededOperator } from "@/lib/operator"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const MAX_BYTES = 50 * 1024 * 1024; + +async function rateLimit(key: string) { + const h = await headers(); + const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown"; + const r = await checkRateLimit(`${key}:${ip}`, { max: 10, windowSec: 30 }); + if (r.limited) throw new Error("Too many uploads"); +} + +export type UploadMediaResult = + | { ok: true; mediaId: string; filename: string; mimeType: string } + | { ok: false; error: string }; + +export async function uploadMediaAction( + _prev: unknown, + formData: FormData, +): Promise { + await rateLimit("media-upload"); + const file = formData.get("file"); + if (!(file instanceof File)) return { ok: false, error: "No file uploaded" }; + if (file.size === 0) return { ok: false, error: "Empty file" }; + if (file.size > MAX_BYTES) return { ok: false, error: "File too large (>50MB)" }; + + const op = await getSeededOperator(); + const buffer = Buffer.from(await file.arrayBuffer()); + const sha256 = createHash("sha256").update(buffer).digest("hex"); + const storagePath = newMediaPath(file.name); + const absolute = absoluteMediaPath(storagePath, env.MEDIA_DIR); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, buffer); + + const [row] = await db + .insert(mediaFiles) + .values({ + operatorId: op.id, + filenameOriginal: file.name, + mimeType: file.type || "application/octet-stream", + sizeBytes: buffer.byteLength, + sha256, + storagePath, + }) + .returning({ id: mediaFiles.id }); + + return { + ok: true, + mediaId: row!.id, + filename: file.name, + mimeType: file.type || "application/octet-stream", + }; +} diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index cd48e30..551de0c 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -4,10 +4,14 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { headers } from "next/headers"; import { eq } from "drizzle-orm"; -import { reminders } from "@cmbot/db"; +import { z } from "zod"; +import { DateTime } from "luxon"; +import { reminders, reminderTargets, reminderMessages } from "@cmbot/db"; +import { DEFAULT_TIMEZONE } from "@cmbot/shared"; import { db } from "@/lib/db"; import { getSeededOperator } from "@/lib/operator"; import { checkRateLimit } from "@/lib/rate-limit"; +import { pgNotifyBot } from "@/lib/notify"; async function rateLimit(key: string) { const h = await headers(); @@ -40,3 +44,97 @@ export async function deleteReminderAction(formData: FormData): Promise { revalidatePath("/reminders" as any); redirect("/reminders" as any); } + +const createReminderSchema = z.object({ + accountId: z.string().uuid(), + groupIds: z.array(z.string().uuid()).min(1, "Pick at least one group"), + text: z.string().nullable().optional(), + mediaId: z.string().uuid().nullable().optional(), + caption: z.string().nullable().optional(), + scheduledAtIso: z.string().datetime(), + timezone: z.string().default(DEFAULT_TIMEZONE), +}); + +export type CreateReminderResult = + | { ok: true; reminderId: string } + | { ok: false; error: string }; + +export async function createReminderAction( + input: z.infer, +): Promise { + await rateLimit("create-reminder"); + const parsed = createReminderSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; + } + const { accountId, groupIds, text, mediaId, caption, scheduledAtIso, timezone } = parsed.data; + + const op = await getSeededOperator(); + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), + }); + if (!account) return { ok: false, error: "Account not yours" }; + + const 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 + const groups = await db.query.whatsappGroups.findMany({ + where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)), + }); + if (groups.length !== groupIds.length) { + return { ok: false, error: "One or more groups don't belong to this account" }; + } + + const reminderId = await db.transaction(async (tx) => { + const [rem] = await tx + .insert(reminders) + .values({ + accountId, + name: (text ?? caption ?? "Reminder").slice(0, 50), + scheduleKind: "one_off", + scheduledAt, + timezone, + status: "active", + createdBy: op.id, + }) + .returning({ id: reminders.id }); + + await tx.insert(reminderTargets).values( + groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })), + ); + + if (text && !mediaId) { + await tx.insert(reminderMessages).values({ + reminderId: rem!.id, + position: 0, + kind: "text", + textContent: text, + mediaId: null, + }); + } else if (mediaId) { + await tx.insert(reminderMessages).values({ + reminderId: rem!.id, + position: 0, + kind: "media", + textContent: caption ?? text ?? null, + mediaId, + }); + } + return rem!.id; + }); + + // Schedule via the bot's IPC consumer (Postgres NOTIFY) + await pgNotifyBot({ + type: "reminder.schedule", + reminderId, + scheduledAtIso: scheduledAt.toISOString(), + }); + + return { ok: true, reminderId }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ace663..c14ac31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.75.0(react@19.2.6)) + '@types/luxon': + specifier: ^3.4.2 + version: 3.7.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -99,6 +102,9 @@ importers: lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.6) + luxon: + specifier: ^3.5.0 + version: 3.7.2 next: specifier: ^16.0.0 version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)