import { NextResponse } from "next/server"; import { isAuthorized } from "@/lib/access-check"; import { SlidingWindowLimiter, botReason, emailQuery, mergeAttribs, parseQuizLead, planExisting, shouldSendResult, type ListmonkSubscriber, type QuizLead, } from "@/lib/quiz-lead"; /** * POST /api/internal/quiz-lead — лид из квиза Typebot (вызов сервер-сервер). * * Подписчик попадает в список квиза, квизовые атрибуты дописываются к * имеющимся — чужие списки, статусы подписок и атрибуты не трогаются * (логика и обоснование — `src/lib/quiz-lead.ts`). * * Защита от ботов: секрет `x-quiz-secret`; квиз быстрее 20 с — молча * пропускаем; общий лимит лидов в час и лимит на адрес; заблокированный в * Listmonk адрес не трогаем; письмо-результат — не чаще раза в сутки на адрес. * IP человека сюда не доходит (вызывает сервер Typebot), поэтому лимиты — не по IP. */ const MAX_PER_HOUR = Number(process.env.QUIZ_LEAD_MAX_PER_HOUR) || 60; const globalLimiter = new SlidingWindowLimiter(MAX_PER_HOUR, 60 * 60 * 1000); const emailLimiter = new SlidingWindowLimiter(3, 60 * 60 * 1000); function auth(): string { return "Basic " + Buffer.from(`${process.env.LISTMONK_USER}:${process.env.LISTMONK_TOKEN}`).toString("base64"); } function lm(path: string): string { return `${process.env.LISTMONK_URL}${path}`; } async function lmJson(path: string, method: string, body?: unknown): Promise { return fetch(lm(path), { method, headers: { "Content-Type": "application/json", Authorization: auth() }, body: body === undefined ? undefined : JSON.stringify(body), }); } async function findSubscriber(email: string): Promise { const res = await fetch(lm("/api/subscribers?per_page=1&query=") + encodeURIComponent(emailQuery(email)), { headers: { Authorization: auth() }, }); if (!res.ok) throw new Error(`lookup ${res.status}`); const json = (await res.json()) as { data?: { results?: ListmonkSubscriber[] } }; return json?.data?.results?.[0] ?? null; } async function sendResult(lead: QuizLead): Promise { const templateId = Number(process.env.LISTMONK_QUIZ_TX_TEMPLATE_ID); if (!templateId) { console.error("quiz-lead: send_result requested, but LISTMONK_QUIZ_TX_TEMPLATE_ID is not set"); return; } const res = await lmJson("/api/tx", "POST", { subscriber_email: lead.email, template_id: templateId, from_email: process.env.QUIZ_RESULT_FROM || "Second Brain ", data: { quiz_type: lead.quizType, utm: lead.utm }, }); if (!res.ok) throw new Error(`tx ${res.status}: ${(await res.text()).slice(0, 200)}`); } export async function POST(req: Request): Promise { if (!isAuthorized(req.headers.get("x-quiz-secret"), process.env.QUIZ_LEAD_SECRET)) { return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } let body: unknown; try { body = await req.json(); } catch { return NextResponse.json({ error: "bad json" }, { status: 400 }); } const lead = parseQuizLead(body); if (!lead) return NextResponse.json({ error: "invalid fields" }, { status: 400 }); const now = new Date(); const bot = botReason(lead, now.getTime()); if (bot) { console.warn("quiz-lead: skipped as bot", bot); return NextResponse.json({ ok: true }); } if (!emailLimiter.allow(lead.email, now.getTime()) || !globalLimiter.allow("all", now.getTime())) { console.warn("quiz-lead: rate limited"); return NextResponse.json({ error: "too many requests" }, { status: 429 }); } const listId = Number(process.env.LISTMONK_QUIZ_LIST_ID); if (!listId) { console.error("quiz-lead: LISTMONK_QUIZ_LIST_ID is not set"); return NextResponse.json({ error: "not configured" }, { status: 503 }); } try { const sendNow = shouldSendResult(lead, null, now); const createRes = await lmJson("/api/subscribers", "POST", { email: lead.email, name: lead.email.split("@")[0], status: "enabled", lists: [listId], // Новый подписчик — только в одном списке квиза, подтверждать больше нечего. preconfirm_subscriptions: true, attribs: mergeAttribs({}, lead, now, sendNow), }); if (createRes.ok) { if (sendNow) await sendResult(lead); return NextResponse.json({ ok: true }); } if (createRes.status !== 409) { console.error("quiz-lead: listmonk create failed", createRes.status, (await createRes.text()).slice(0, 300)); return NextResponse.json({ error: "listmonk" }, { status: 502 }); } const existing = await findSubscriber(lead.email); if (!existing) { console.error("quiz-lead: 409, but subscriber not found"); return NextResponse.json({ error: "lookup failed" }, { status: 502 }); } const plan = planExisting(existing, lead, listId, now); if (plan.kind === "skip") return NextResponse.json({ ok: true }); if (plan.addToList) { const addRes = await lmJson("/api/subscribers/lists", "PUT", { ids: [existing.id], action: "add", target_list_ids: [listId], status: "confirmed", }); if (!addRes.ok) { console.error("quiz-lead: add to list failed", addRes.status, (await addRes.text()).slice(0, 300)); return NextResponse.json({ error: "listmonk" }, { status: 502 }); } } const putRes = await lmJson(`/api/subscribers/${existing.id}`, "PUT", plan.put); if (!putRes.ok) { console.error("quiz-lead: attribs update failed", putRes.status, (await putRes.text()).slice(0, 300)); return NextResponse.json({ error: "listmonk" }, { status: 502 }); } if (plan.sendResult) await sendResult(lead); } catch (e) { console.error("quiz-lead: upstream error", e); return NextResponse.json({ error: "upstream" }, { status: 502 }); } return NextResponse.json({ ok: true }); }