From bb6c806cdd4c3371da18b9f6ccee5d93c5dd7a14 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 22 Jun 2026 14:27:54 +0500 Subject: [PATCH] =?UTF-8?q?Add=20quiz-lead=20internal=20endpoint=20for=20T?= =?UTF-8?q?ypebot=20=E2=86=92=20Listmonk=20funnel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 8 ++ src/app/api/internal/quiz-lead/route.ts | 151 ++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/app/api/internal/quiz-lead/route.ts diff --git a/.env.example b/.env.example index f54d3c5..3a14117 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,11 @@ INTERNAL_GRANT_SECRET="" # Freemium: slug бесплатного курса, в который авто-записываем новых юзеров при регистрации 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="" diff --git a/src/app/api/internal/quiz-lead/route.ts b/src/app/api/internal/quiz-lead/route.ts new file mode 100644 index 0000000..e7960f4 --- /dev/null +++ b/src/app/api/internal/quiz-lead/route.ts @@ -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 { + // --- 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; + 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 }); +}