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 {
|
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>
|
||||||
|
|
||||||
|
|||||||
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