PUT /api/subscribers/{id} with lists=[quiz] replaced all lists and attribs:
a Press reader taking the quiz dropped out of Press, buyers lost course and
updates_until, status "enabled" lifted blocklisting. Verified on Listmonk 6.2.
Now: add to the quiz list via PUT /api/subscribers/lists (action add), write
attribs with current lists and status and preconfirm_subscriptions=false,
leave blocklisted subscribers untouched, keep first-touch utm_*.
Bot protection: constant-time secret check, quizzes faster than 20 s are
dropped silently, global hourly cap and per-email cap, strict email check
(the address goes into a Listmonk SQL filter), result email at most once a
day per address. Quiz v2 fields: quiz_type, quiz_date, quiz_answers,
quiz_utm, optional result email through LMS (send_result). v1 bot payload
keeps working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
6.2 KiB
TypeScript
161 lines
6.2 KiB
TypeScript
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<Response> {
|
|
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<ListmonkSubscriber | null> {
|
|
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<void> {
|
|
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 <delivery@mailsend.second-brain.ru>",
|
|
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<NextResponse> {
|
|
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 });
|
|
}
|