Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions src/app/(app)/whatsapp-cloud-pilot/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"use client";

import { Loader2, Send, ListChecks } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";

interface CloudMessageTemplate {
id: string;
name: string;
status: string;
category: string;
language: string;
}

/**
* PUL-16 pilot: a minimal, real exercise of the Meta WhatsApp Cloud API,
* against the temporary test business number from the App Dashboard's own
* API Setup page — not the merchant-facing send path (that's PUL-18) and
* not reachable from the main sidebar nav. Exists so App Review's
* screencast requirement has a genuine, working flow to record: a real
* send via whatsapp_business_messaging, and a real template list via
* whatsapp_business_management.
*/
export default function WhatsAppCloudPilotPage() {
const [mode, setMode] = useState<"template" | "text">("template");
const [to, setTo] = useState("");
const [message, setMessage] = useState("Hello from the PulseCommerce Cloud API pilot.");
const [sending, setSending] = useState(false);

const [templates, setTemplates] = useState<CloudMessageTemplate[] | null>(null);
const [loadingTemplates, setLoadingTemplates] = useState(false);

const sendTest = async () => {
setSending(true);
try {
const res = await fetch("/api/whatsapp-cloud/test-send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to, message, mode }),
});
const body = await res.json();
if (!res.ok) {
toast.error(body.error ?? "The send failed.");
return;
}
toast.success(`Sent (${body.mode}) — message id ${body.messageId}`);
} finally {
setSending(false);
}
};

const loadTemplates = async () => {
setLoadingTemplates(true);
try {
const res = await fetch("/api/whatsapp-cloud/templates", { cache: "no-store" });
const body = await res.json();
if (!res.ok) {
toast.error(body.error ?? "Could not list templates.");
return;
}
setTemplates(body.templates);
} finally {
setLoadingTemplates(false);
}
};

return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="size-4" /> Send a test message
</CardTitle>
<CardDescription>
Uses whatsapp_business_messaging against Meta&apos;s test business number. The
recipient must be one of the up to 5 numbers verified as a test recipient in the App
Dashboard.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Tabs value={mode} onValueChange={(v) => setMode(v as "template" | "text")}>
<TabsList>
<TabsTrigger value="template">Template (reliable)</TabsTrigger>
<TabsTrigger value="text">Free-form text</TabsTrigger>
</TabsList>
</Tabs>
{mode === "text" && (
<p className="text-xs text-muted-foreground">
Only delivers if the recipient has messaged this business number first, opening a
24h window — otherwise Meta accepts the call and returns a message id, but the
message is never actually delivered.
</p>
)}

<Input
placeholder="Recipient, e.g. 916383984698"
value={to}
onChange={(e) => setTo(e.target.value)}
/>
{mode === "text" && (
<Textarea value={message} onChange={(e) => setMessage(e.target.value)} rows={3} />
)}
<Button onClick={sendTest} disabled={sending || !to || (mode === "text" && !message)}>
{sending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
Send
</Button>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ListChecks className="size-4" /> Message templates
</CardTitle>
<CardDescription>
Uses whatsapp_business_management to list templates on the connected WhatsApp
Business Account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button onClick={loadTemplates} disabled={loadingTemplates} variant="outline">
{loadingTemplates ? <Loader2 className="size-4 animate-spin" /> : <ListChecks className="size-4" />}
List templates
</Button>

{templates && (
<div className="space-y-2">
{templates.length === 0 ? (
<p className="text-sm text-muted-foreground">No templates on this account yet.</p>
) : (
templates.map((t) => (
<div
key={t.id}
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
>
<span>
{t.name} <span className="text-muted-foreground">({t.language})</span>
</span>
<Badge variant="outline">{t.status}</Badge>
</div>
))
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
26 changes: 26 additions & 0 deletions src/app/api/whatsapp-cloud/templates/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { requireTenant } from "@/lib/auth/tenant";
import { listCloudMessageTemplates, WhatsAppCloudApiError } from "@/lib/whatsapp-cloud/client";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* PUL-16 pilot: lists message templates on the test WABA, using
* whatsapp_business_management. See test-send/route.ts's own comment for
* why this exists as a real, minimal, working call rather than nothing --
* App Review needs to see each permission actually exercised by the app.
*/
export async function GET(request: Request) {
const resolved = await requireTenant(request);
if (!resolved.ok) return resolved.response;

try {
const templates = await listCloudMessageTemplates();
return NextResponse.json({ templates });
} catch (error) {
const message =
error instanceof WhatsAppCloudApiError ? error.message : "Could not list message templates.";
return NextResponse.json({ error: message }, { status: 502 });
}
}
68 changes: 68 additions & 0 deletions src/app/api/whatsapp-cloud/test-send/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireTenant } from "@/lib/auth/tenant";
import {
sendCloudTemplateMessage,
sendCloudTextMessage,
WhatsAppCloudApiError,
} from "@/lib/whatsapp-cloud/client";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const schema = z.object({
to: z.string().min(6),
message: z.string().min(1).max(4096).optional(),
// "template" reliably delivers business-initiated with no open-window
// requirement (confirmed the hard way -- a "text" send outside an open
// 24h window returns a real message id from Meta but is never actually
// delivered). Defaults to template for exactly that reason.
mode: z.enum(["template", "text"]).default("template"),
});

/**
* PUL-16 pilot: one real send through Meta's Cloud API, using the temporary
* test token and test business number from the App Dashboard. Not the
* merchant-facing send path -- that's PUL-18, gated on the billing decision
* and template workflow. This exists so App Review has a real, working
* whatsapp_business_messaging call to review, not a mock.
*
* Deliberately no audience/customer-key input, same reasoning as
* /api/whatsapp/test: this can only ever message a number typed by the
* operator, never a customer pulled from store data.
*/
export async function POST(request: Request) {
const resolved = await requireTenant(request);
if (!resolved.ok) return resolved.response;

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}

const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(" ") },
{ status: 422 },
);
}

if (parsed.data.mode === "text" && !parsed.data.message) {
return NextResponse.json({ error: "message is required for a text send." }, { status: 422 });
}

try {
const sent =
parsed.data.mode === "template"
? await sendCloudTemplateMessage(parsed.data.to)
: await sendCloudTextMessage(parsed.data.to, parsed.data.message!);
return NextResponse.json({ sent: true, messageId: sent.messageId, mode: parsed.data.mode });
} catch (error) {
const message =
error instanceof WhatsAppCloudApiError ? error.message : "The Cloud API rejected the send.";
return NextResponse.json({ error: message }, { status: 502 });
}
}
79 changes: 79 additions & 0 deletions src/lib/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export const openApiDocument = {
{ name: "Sync", description: "The local mirror of your WooCommerce store" },
{ name: "Billing", description: "Plans, usage, invoices and Razorpay subscriptions" },
{ name: "Order confirmations", description: "WhatsApp thank-you messages sent on new orders" },
{
name: "WhatsApp Cloud API pilot",
description:
"PUL-16: a minimal pilot against Meta's official Cloud API test business number, built " +
"for App Review. Not the merchant-facing send path.",
},
],
paths: {
"/api/sync": {
Expand Down Expand Up @@ -883,6 +889,79 @@ export const openApiDocument = {
},
},

"/api/whatsapp-cloud/test-send": {
post: {
tags: ["WhatsApp Cloud API pilot"],
summary: "Send one message via Meta's Cloud API test business number",
description:
"PUL-16 pilot, not the merchant-facing send path. Exercises whatsapp_business_messaging " +
"against the temporary test token and test business number from the App Dashboard's own " +
"API Setup page — never per-tenant credentials. Recipient must be one of the up to 5 " +
"numbers verified as a test recipient in the App Dashboard. mode \"template\" (default) " +
"sends the pre-approved hello_world template, which delivers regardless of an open " +
"customer-service window; mode \"text\" sends free-form and only actually delivers " +
"within an open 24h window — confirmed the hard way that Meta's API accepts and returns " +
"a message id for a \"text\" send outside that window without ever delivering it.",
requestBody: {
required: true,
content: json({
type: "object",
properties: {
to: { type: "string", example: "916383984698" },
message: { type: "string", maxLength: 4096, description: "Required when mode is \"text\"." },
mode: { type: "string", enum: ["template", "text"], default: "template" },
},
required: ["to"],
}),
},
responses: {
200: {
description: "Sent",
content: json({
type: "object",
properties: { sent: { type: "boolean" }, messageId: { type: "string" } },
}),
},
422: errorResponse("Invalid recipient or message"),
502: errorResponse("The Cloud API rejected the send"),
},
},
},

"/api/whatsapp-cloud/templates": {
get: {
tags: ["WhatsApp Cloud API pilot"],
summary: "List message templates on the test WhatsApp Business Account",
description:
"PUL-16 pilot. Exercises whatsapp_business_management by resolving the test phone " +
"number's parent WABA and listing its message templates.",
responses: {
200: {
description: "Templates",
content: json({
type: "object",
properties: {
templates: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
status: { type: "string" },
category: { type: "string" },
language: { type: "string" },
},
},
},
},
}),
},
502: errorResponse("Could not list message templates"),
},
},
},

"/api/ai/chat": {
post: {
tags: ["Assistant"],
Expand Down
Loading
Loading