Fix quiz-lead wiping lists and attributes of existing subscribers

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>
This commit is contained in:
2026-11-07 14:17:54 +05:00
co-authored by Claude Opus 5.5
parent 651a115ab7
commit 767a0d5a6e
4 changed files with 536 additions and 110 deletions
+119 -110
View File
@@ -1,59 +1,79 @@
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";
const ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"] as const;
type Archetype = (typeof ARCHETYPES)[number];
/**
* POST /api/internal/quiz-lead — лид из квиза Typebot (вызов сервер-сервер).
*
* Подписчик попадает в список квиза, квизовые атрибуты дописываются к
* имеющимся — чужие списки, статусы подписок и атрибуты не трогаются
* (логика и обоснование — `src/lib/quiz-lead.ts`).
*
* Защита от ботов: секрет `x-quiz-secret`; квиз быстрее 20 с — молча
* пропускаем; общий лимит лидов в час и лимит на адрес; заблокированный в
* Listmonk адрес не трогаем; письмо-результат — не чаще раза в сутки на адрес.
* IP человека сюда не доходит (вызывает сервер Typebot), поэтому лимиты — не по IP.
*/
interface ListmonkSubscriber {
id: number;
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");
}
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 {
function lm(path: string): string {
return `${process.env.LISTMONK_URL}${path}`;
}
function getListId(): number {
return Number(process.env.LISTMONK_QUIZ_LIST_ID);
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),
});
}
function isValidEmail(email: string): boolean {
return /^[^@]+@[^@]+\.[^@]+$/.test(email);
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;
}
function isValidArchetype(value: string): value is Archetype {
return (ARCHETYPES as readonly string[]).includes(value);
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)}`);
}
/**
* 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) {
if (!isAuthorized(req.headers.get("x-quiz-secret"), process.env.QUIZ_LEAD_SECRET)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// --- Parse body ---
let body: unknown;
try {
body = await req.json();
@@ -61,87 +81,76 @@ export async function POST(req: Request): Promise<NextResponse> {
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);
const lead = parseQuizLead(body);
if (!lead) return NextResponse.json({ error: "invalid fields" }, { status: 400 });
if (!isValidEmail(email) || !isValidArchetype(archetype) || !Number.isFinite(score)) {
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 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 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 });
}
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,
}),
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.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(),
);
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 });