Add quiz-lead internal endpoint for Typebot → Listmonk funnel
POST /api/internal/quiz-lead accepts archetype quiz results and idempotently upserts subscriber in Listmonk (create on 201, PUT on 409). Documents QUIZ_LEAD_SECRET and LISTMONK_* vars in .env.example. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"] as const;
|
||||
type Archetype = (typeof ARCHETYPES)[number];
|
||||
|
||||
interface ListmonkSubscriber {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ListmonkSearchResult {
|
||||
data?: {
|
||||
results?: ListmonkSubscriber[];
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuth(): string {
|
||||
return (
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
`${process.env.LISTMONK_USER}:${process.env.LISTMONK_TOKEN}`,
|
||||
).toString("base64")
|
||||
);
|
||||
}
|
||||
|
||||
function listmonkUrl(path: string): string {
|
||||
return `${process.env.LISTMONK_URL}${path}`;
|
||||
}
|
||||
|
||||
function getListId(): number {
|
||||
return Number(process.env.LISTMONK_QUIZ_LIST_ID);
|
||||
}
|
||||
|
||||
function isValidEmail(email: string): boolean {
|
||||
return /^[^@]+@[^@]+\.[^@]+$/.test(email);
|
||||
}
|
||||
|
||||
function isValidArchetype(value: string): value is Archetype {
|
||||
return (ARCHETYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/internal/quiz-lead
|
||||
*
|
||||
* Receives a completed Typebot quiz lead and idempotently upserts
|
||||
* the subscriber in Listmonk with archetype + score attributes.
|
||||
*
|
||||
* Auth: x-quiz-secret header must match QUIZ_LEAD_SECRET env var.
|
||||
*/
|
||||
export async function POST(req: Request): Promise<NextResponse> {
|
||||
// --- Auth ---
|
||||
const secret = req.headers.get("x-quiz-secret");
|
||||
if (!secret || secret !== process.env.QUIZ_LEAD_SECRET) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// --- Parse body ---
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
|
||||
// --- Validate fields ---
|
||||
const raw = body as Record<string, unknown>;
|
||||
const email = String(raw.email ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const archetype = String(raw.archetype ?? "");
|
||||
const score = Number(raw.score);
|
||||
|
||||
if (!isValidEmail(email) || !isValidArchetype(archetype) || !Number.isFinite(score)) {
|
||||
return NextResponse.json({ error: "invalid fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
const attribs = {
|
||||
archetype,
|
||||
score,
|
||||
utm_source: raw.utm_source != null ? String(raw.utm_source) : null,
|
||||
utm_medium: raw.utm_medium != null ? String(raw.utm_medium) : null,
|
||||
utm_campaign: raw.utm_campaign != null ? String(raw.utm_campaign) : null,
|
||||
};
|
||||
|
||||
const auth = buildAuth();
|
||||
const listId = getListId();
|
||||
|
||||
// --- Upsert to Listmonk ---
|
||||
try {
|
||||
const createRes = await fetch(listmonkUrl("/api/subscribers"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: auth },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
name: email.split("@")[0],
|
||||
lists: [listId],
|
||||
attribs,
|
||||
status: "enabled",
|
||||
preconfirm_subscriptions: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (createRes.status === 409) {
|
||||
// Subscriber exists — look up by email and update
|
||||
const searchRes = await fetch(
|
||||
listmonkUrl("/api/subscribers?query=") +
|
||||
encodeURIComponent(`subscribers.email='${email}'`),
|
||||
{ headers: { Authorization: auth } },
|
||||
);
|
||||
const searchJson = (await searchRes.json()) as ListmonkSearchResult;
|
||||
const existingId = searchJson?.data?.results?.[0]?.id;
|
||||
|
||||
if (!existingId) {
|
||||
console.error("quiz-lead: subscriber lookup failed for", email);
|
||||
return NextResponse.json({ error: "lookup failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
const updateRes = await fetch(listmonkUrl(`/api/subscribers/${existingId}`), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: auth },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
name: email.split("@")[0],
|
||||
lists: [listId],
|
||||
attribs,
|
||||
status: "enabled",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateRes.ok) {
|
||||
console.error(
|
||||
"quiz-lead: subscriber update failed",
|
||||
updateRes.status,
|
||||
await updateRes.text(),
|
||||
);
|
||||
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||
}
|
||||
} else if (!createRes.ok) {
|
||||
console.error(
|
||||
"quiz-lead: listmonk create failed",
|
||||
createRes.status,
|
||||
await createRes.text(),
|
||||
);
|
||||
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("quiz-lead: upstream error", e);
|
||||
return NextResponse.json({ error: "upstream" }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user