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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user