bb6c806cdd
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>
152 lines
4.3 KiB
TypeScript
152 lines
4.3 KiB
TypeScript
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 });
|
|
}
|