feat(web): send-test server action wired into group detail

This commit is contained in:
yiekheng 2026-05-09 23:44:22 +08:00
parent 68b46f8d71
commit 83a19d4800
3 changed files with 105 additions and 19 deletions

View File

@ -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<SendTestResult> {
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}` };
}

View File

@ -3,12 +3,10 @@ import { notFound } from "next/navigation";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
UsersIcon, UsersIcon,
SendIcon,
BellPlusIcon, BellPlusIcon,
ClockIcon, ClockIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { import {
Card, Card,
CardContent, CardContent,
@ -16,6 +14,7 @@ import {
CardTitle, CardTitle,
CardDescription, CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { SendTestForm } from "@/components/send-test-form";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getGroup } from "@/lib/queries"; import { getGroup } from "@/lib/queries";
@ -23,11 +22,6 @@ interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
async function _sendTestStub(_formData: FormData) {
"use server";
// wired in Task 18
}
export default async function GroupDetailPage({ params }: Props) { export default async function GroupDetailPage({ params }: Props) {
const { id } = await params; const { id } = await params;
@ -82,18 +76,7 @@ export default async function GroupDetailPage({ params }: Props) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form action={_sendTestStub} className="space-y-3"> <SendTestForm groupId={group.id} />
<Textarea
name="text"
placeholder="Type your test message…"
rows={3}
className="resize-none"
/>
<Button type="submit" size="sm">
<SendIcon />
Send Message
</Button>
</form>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -0,0 +1,47 @@
"use client";
import { useActionState } from "react";
import { sendTestAction, type SendTestResult } from "@/actions/groups";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
const initial: SendTestResult | null = null;
export function SendTestForm({ groupId }: { groupId: string }) {
const [state, formAction, isPending] = useActionState(sendTestAction, initial);
return (
<form action={formAction} className="space-y-3">
<input type="hidden" name="groupId" value={groupId} />
<div>
<Label htmlFor="text" className="text-sm">
Message
</Label>
<Textarea
id="text"
name="text"
placeholder="What should I send to this group?"
rows={4}
required
maxLength={4000}
className="mt-1"
/>
</div>
{state?.ok === false && (
<p className="text-sm text-destructive" role="alert">
{state.error}
</p>
)}
{state?.ok === true && (
<p className="text-sm text-green-600 dark:text-green-400" role="status">
{state.message}
</p>
)}
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? "Sending…" : "Send Test"}
</Button>
</div>
</form>
);
}