feat(web): create reminder + media upload server actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-09 23:48:35 +08:00
parent 6916f5a0ed
commit 3d470069d3
4 changed files with 170 additions and 1 deletions

View File

@ -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",

View File

@ -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<UploadMediaResult> {
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",
};
}

View File

@ -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<void> {
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<typeof createReminderSchema>,
): Promise<CreateReminderResult> {
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 };
}

6
pnpm-lock.yaml generated
View File

@ -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)