From 83a19d4800cca059a21cba4f4ec59dd88afedd5e Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 23:44:22 +0800 Subject: [PATCH] feat(web): send-test server action wired into group detail --- apps/web/src/actions/groups.ts | 56 ++++++++++++++++++++++ apps/web/src/app/groups/[id]/page.tsx | 21 +------- apps/web/src/components/send-test-form.tsx | 47 ++++++++++++++++++ 3 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/actions/groups.ts create mode 100644 apps/web/src/components/send-test-form.tsx diff --git a/apps/web/src/actions/groups.ts b/apps/web/src/actions/groups.ts new file mode 100644 index 0000000..be9843d --- /dev/null +++ b/apps/web/src/actions/groups.ts @@ -0,0 +1,56 @@ +"use server"; + +import { headers } from "next/headers"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { getSeededOperator } from "@/lib/operator"; +import { pgNotifyBot } from "@/lib/notify"; +import { checkRateLimit } from "@/lib/rate-limit"; + +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: 30, windowSec: 10 }); + if (r.limited) throw new Error("Too many requests"); +} + +const sendTestSchema = z.object({ + groupId: z.string().uuid(), + text: z.string().trim().min(1, "Message is empty").max(4000, "Message too long"), +}); + +export type SendTestResult = + | { ok: true; message: string } + | { ok: false; error: string }; + +export async function sendTestAction(_prev: unknown, formData: FormData): Promise { + await rateLimit("send-test"); + const parsed = sendTestSchema.safeParse({ + groupId: formData.get("groupId"), + text: formData.get("text"), + }); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; + } + + const op = await getSeededOperator(); + const group = await db.query.whatsappGroups.findFirst({ + where: (g, { eq }) => eq(g.id, parsed.data.groupId), + }); + if (!group) return { ok: false, error: "Group not found" }; + + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, op.id)), + }); + if (!account) return { ok: false, error: "Group not yours" }; + if (account.status !== "connected") { + return { ok: false, error: "Account not connected" }; + } + + await pgNotifyBot({ + type: "group.send_test", + groupId: parsed.data.groupId, + text: parsed.data.text, + }); + return { ok: true, message: `Sending to ${group.name}…` }; +} diff --git a/apps/web/src/app/groups/[id]/page.tsx b/apps/web/src/app/groups/[id]/page.tsx index 2498ca2..292f525 100644 --- a/apps/web/src/app/groups/[id]/page.tsx +++ b/apps/web/src/app/groups/[id]/page.tsx @@ -3,12 +3,10 @@ import { notFound } from "next/navigation"; import { ArrowLeftIcon, UsersIcon, - SendIcon, BellPlusIcon, ClockIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent, @@ -16,6 +14,7 @@ import { CardTitle, CardDescription, } from "@/components/ui/card"; +import { SendTestForm } from "@/components/send-test-form"; import { getSeededOperator } from "@/lib/operator"; import { getGroup } from "@/lib/queries"; @@ -23,11 +22,6 @@ interface Props { params: Promise<{ id: string }>; } -async function _sendTestStub(_formData: FormData) { - "use server"; - // wired in Task 18 -} - export default async function GroupDetailPage({ params }: Props) { const { id } = await params; @@ -82,18 +76,7 @@ export default async function GroupDetailPage({ params }: Props) { -
-