feat(web): create reminder + media upload server actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6916f5a0ed
commit
3d470069d3
@ -15,11 +15,13 @@
|
|||||||
"@cmbot/db": "workspace:*",
|
"@cmbot/db": "workspace:*",
|
||||||
"@cmbot/shared": "workspace:*",
|
"@cmbot/shared": "workspace:*",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.36.0",
|
"drizzle-orm": "^0.36.0",
|
||||||
"geist": "^1.7.0",
|
"geist": "^1.7.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
|||||||
63
apps/web/src/actions/media.ts
Normal file
63
apps/web/src/actions/media.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -4,10 +4,14 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { eq } from "drizzle-orm";
|
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 { db } from "@/lib/db";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { checkRateLimit } from "@/lib/rate-limit";
|
import { checkRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { pgNotifyBot } from "@/lib/notify";
|
||||||
|
|
||||||
async function rateLimit(key: string) {
|
async function rateLimit(key: string) {
|
||||||
const h = await headers();
|
const h = await headers();
|
||||||
@ -40,3 +44,97 @@ export async function deleteReminderAction(formData: FormData): Promise<void> {
|
|||||||
revalidatePath("/reminders" as any);
|
revalidatePath("/reminders" as any);
|
||||||
redirect("/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
6
pnpm-lock.yaml
generated
@ -84,6 +84,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.75.0(react@19.2.6))
|
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:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@ -99,6 +102,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.14.0
|
specifier: ^1.14.0
|
||||||
version: 1.14.0(react@19.2.6)
|
version: 1.14.0(react@19.2.6)
|
||||||
|
luxon:
|
||||||
|
specifier: ^3.5.0
|
||||||
|
version: 3.7.2
|
||||||
next:
|
next:
|
||||||
specifier: ^16.0.0
|
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)
|
version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user