feat(web): send-test server action wired into group detail
This commit is contained in:
parent
68b46f8d71
commit
83a19d4800
56
apps/web/src/actions/groups.ts
Normal file
56
apps/web/src/actions/groups.ts
Normal 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}…` };
|
||||
}
|
||||
@ -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) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={_sendTestStub} className="space-y-3">
|
||||
<Textarea
|
||||
name="text"
|
||||
placeholder="Type your test message…"
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
<SendIcon />
|
||||
Send Message
|
||||
</Button>
|
||||
</form>
|
||||
<SendTestForm groupId={group.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
47
apps/web/src/components/send-test-form.tsx
Normal file
47
apps/web/src/components/send-test-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user