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:
2026-06-22 14:27:54 +05:00
parent 9fbb7aea6c
commit bb6c806cdd
2 changed files with 159 additions and 0 deletions
+8
View File
@@ -24,3 +24,11 @@ INTERNAL_GRANT_SECRET=""
# Freemium: slug бесплатного курса, в который авто-записываем новых юзеров при регистрации # Freemium: slug бесплатного курса, в который авто-записываем новых юзеров при регистрации
FREE_COURSE_SLUG=obsidian-start FREE_COURSE_SLUG=obsidian-start
# Freemium quiz funnel (Typebot → /api/internal/quiz-lead)
QUIZ_LEAD_SECRET=""
# Listmonk API base — публичный домен (Listmonk живёт на Hetzner, наружу через newsletter.)
LISTMONK_URL="https://newsletter.second-brain.ru"
LISTMONK_USER="apiuser"
LISTMONK_TOKEN=""
LISTMONK_QUIZ_LIST_ID=""
+151
View File
@@ -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 });
}