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:
@@ -37,6 +37,11 @@ LISTMONK_URL="https://newsletter.second-brain.ru"
|
|||||||
LISTMONK_USER="apiuser"
|
LISTMONK_USER="apiuser"
|
||||||
LISTMONK_TOKEN=""
|
LISTMONK_TOKEN=""
|
||||||
LISTMONK_QUIZ_LIST_ID=""
|
LISTMONK_QUIZ_LIST_ID=""
|
||||||
|
# Квиз v2: письмо-результат через LMS (tx-шаблон с ветками по quiz_type); пусто — LMS писем не шлёт
|
||||||
|
LISTMONK_QUIZ_TX_TEMPLATE_ID=""
|
||||||
|
QUIZ_RESULT_FROM="Second Brain <delivery@mailsend.second-brain.ru>"
|
||||||
|
# Защита от ботов: потолок лидов квиза в час (по умолчанию 60)
|
||||||
|
QUIZ_LEAD_MAX_PER_HOUR=""
|
||||||
|
|
||||||
# Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения
|
# Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения
|
||||||
# показываем лестницу-оффер (трипвайр + полный курс)
|
# показываем лестницу-оффер (трипвайр + полный курс)
|
||||||
|
|||||||
@@ -1,59 +1,79 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 {
|
const MAX_PER_HOUR = Number(process.env.QUIZ_LEAD_MAX_PER_HOUR) || 60;
|
||||||
id: number;
|
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 {
|
function lm(path: string): string {
|
||||||
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}`;
|
return `${process.env.LISTMONK_URL}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getListId(): number {
|
async function lmJson(path: string, method: string, body?: unknown): Promise<Response> {
|
||||||
return Number(process.env.LISTMONK_QUIZ_LIST_ID);
|
return fetch(lm(path), {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: auth() },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidEmail(email: string): boolean {
|
async function findSubscriber(email: string): Promise<ListmonkSubscriber | null> {
|
||||||
return /^[^@]+@[^@]+\.[^@]+$/.test(email);
|
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 {
|
async function sendResult(lead: QuizLead): Promise<void> {
|
||||||
return (ARCHETYPES as readonly string[]).includes(value);
|
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> {
|
export async function POST(req: Request): Promise<NextResponse> {
|
||||||
// --- Auth ---
|
if (!isAuthorized(req.headers.get("x-quiz-secret"), process.env.QUIZ_LEAD_SECRET)) {
|
||||||
const secret = req.headers.get("x-quiz-secret");
|
|
||||||
if (!secret || secret !== process.env.QUIZ_LEAD_SECRET) {
|
|
||||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Parse body ---
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
@@ -61,87 +81,76 @@ export async function POST(req: Request): Promise<NextResponse> {
|
|||||||
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Validate fields ---
|
const lead = parseQuizLead(body);
|
||||||
const raw = body as Record<string, unknown>;
|
if (!lead) return NextResponse.json({ error: "invalid fields" }, { status: 400 });
|
||||||
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)) {
|
const now = new Date();
|
||||||
return NextResponse.json({ error: "invalid fields" }, { status: 400 });
|
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 = {
|
const listId = Number(process.env.LISTMONK_QUIZ_LIST_ID);
|
||||||
archetype,
|
if (!listId) {
|
||||||
score,
|
console.error("quiz-lead: LISTMONK_QUIZ_LIST_ID is not set");
|
||||||
utm_source: raw.utm_source != null ? String(raw.utm_source) : null,
|
return NextResponse.json({ error: "not configured" }, { status: 503 });
|
||||||
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 {
|
try {
|
||||||
const createRes = await fetch(listmonkUrl("/api/subscribers"), {
|
const sendNow = shouldSendResult(lead, null, now);
|
||||||
method: "POST",
|
const createRes = await lmJson("/api/subscribers", "POST", {
|
||||||
headers: { "Content-Type": "application/json", Authorization: auth },
|
email: lead.email,
|
||||||
body: JSON.stringify({
|
name: lead.email.split("@")[0],
|
||||||
email,
|
|
||||||
name: email.split("@")[0],
|
|
||||||
lists: [listId],
|
|
||||||
attribs,
|
|
||||||
status: "enabled",
|
status: "enabled",
|
||||||
|
lists: [listId],
|
||||||
|
// Новый подписчик — только в одном списке квиза, подтверждать больше нечего.
|
||||||
preconfirm_subscriptions: true,
|
preconfirm_subscriptions: true,
|
||||||
}),
|
attribs: mergeAttribs({}, lead, now, sendNow),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createRes.status === 409) {
|
if (createRes.ok) {
|
||||||
// Subscriber exists — look up by email and update
|
if (sendNow) await sendResult(lead);
|
||||||
const searchRes = await fetch(
|
return NextResponse.json({ ok: true });
|
||||||
listmonkUrl("/api/subscribers?query=") +
|
}
|
||||||
encodeURIComponent(`subscribers.email='${email}'`),
|
if (createRes.status !== 409) {
|
||||||
{ headers: { Authorization: auth } },
|
console.error("quiz-lead: listmonk create failed", createRes.status, (await createRes.text()).slice(0, 300));
|
||||||
);
|
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||||
const searchJson = (await searchRes.json()) as ListmonkSearchResult;
|
}
|
||||||
const existingId = searchJson?.data?.results?.[0]?.id;
|
|
||||||
|
|
||||||
if (!existingId) {
|
const existing = await findSubscriber(lead.email);
|
||||||
console.error("quiz-lead: subscriber lookup failed for", email);
|
if (!existing) {
|
||||||
|
console.error("quiz-lead: 409, but subscriber not found");
|
||||||
return NextResponse.json({ error: "lookup failed" }, { status: 502 });
|
return NextResponse.json({ error: "lookup failed" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateRes = await fetch(listmonkUrl(`/api/subscribers/${existingId}`), {
|
const plan = planExisting(existing, lead, listId, now);
|
||||||
method: "PUT",
|
if (plan.kind === "skip") return NextResponse.json({ ok: true });
|
||||||
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
name: email.split("@")[0],
|
|
||||||
lists: [listId],
|
|
||||||
attribs,
|
|
||||||
status: "enabled",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!updateRes.ok) {
|
if (plan.addToList) {
|
||||||
console.error(
|
const addRes = await lmJson("/api/subscribers/lists", "PUT", {
|
||||||
"quiz-lead: subscriber update failed",
|
ids: [existing.id],
|
||||||
updateRes.status,
|
action: "add",
|
||||||
await updateRes.text(),
|
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 });
|
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||||
}
|
}
|
||||||
} else if (!createRes.ok) {
|
}
|
||||||
console.error(
|
|
||||||
"quiz-lead: listmonk create failed",
|
const putRes = await lmJson(`/api/subscribers/${existing.id}`, "PUT", plan.put);
|
||||||
createRes.status,
|
if (!putRes.ok) {
|
||||||
await createRes.text(),
|
console.error("quiz-lead: attribs update failed", putRes.status, (await putRes.text()).slice(0, 300));
|
||||||
);
|
|
||||||
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plan.sendResult) await sendResult(lead);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("quiz-lead: upstream error", e);
|
console.error("quiz-lead: upstream error", e);
|
||||||
return NextResponse.json({ error: "upstream" }, { status: 502 });
|
return NextResponse.json({ error: "upstream" }, { status: 502 });
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
SlidingWindowLimiter,
|
||||||
|
almatyDate,
|
||||||
|
botReason,
|
||||||
|
emailQuery,
|
||||||
|
isPlausibleEmail,
|
||||||
|
mergeAttribs,
|
||||||
|
parseQuizLead,
|
||||||
|
planExisting,
|
||||||
|
shouldSendResult,
|
||||||
|
type ListmonkSubscriber,
|
||||||
|
} from "../quiz-lead";
|
||||||
|
|
||||||
|
const NOW = new Date("2026-11-20T10:00:00Z");
|
||||||
|
const QUIZ_LIST = 48;
|
||||||
|
|
||||||
|
const v2 = (extra: Record<string, unknown> = {}) =>
|
||||||
|
parseQuizLead({ email: " Reader@Example.com ", quiz_type: "t3-ready", utm_source: "youtube", ...extra })!;
|
||||||
|
|
||||||
|
const v1 = () =>
|
||||||
|
parseQuizLead({ email: "a@example.com", archetype: "gardener", score: 70, utm_source: "telegram" })!;
|
||||||
|
|
||||||
|
/** Подписчик Press и покупатель — тот, кого прежний код и выбрасывал из Press. */
|
||||||
|
const pressBuyer = (extra: Partial<ListmonkSubscriber> = {}): ListmonkSubscriber => ({
|
||||||
|
id: 7,
|
||||||
|
email: "reader@example.com",
|
||||||
|
name: "Читатель",
|
||||||
|
status: "enabled",
|
||||||
|
lists: [
|
||||||
|
{ id: 3, subscription_status: "confirmed" },
|
||||||
|
{ id: 58, subscription_status: "unconfirmed" },
|
||||||
|
{ id: 13, subscription_status: "unsubscribed" },
|
||||||
|
],
|
||||||
|
attribs: { course: "obsidian", updates_until: "20270101", utm_source: "first-touch" },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quiz-lead: разбор входа", () => {
|
||||||
|
it("v2: тип по проблеме, почта нормализуется", () => {
|
||||||
|
const l = v2({ quiz_answers: "1a 2b", utm_content: "{{utm_content}}", started_at: 1000, send_result: true });
|
||||||
|
expect(l.email).toBe("reader@example.com");
|
||||||
|
expect(l.quizType).toBe("t3-ready");
|
||||||
|
expect(l.archetype).toBeNull();
|
||||||
|
expect(l.answers).toBe("1a 2b");
|
||||||
|
expect(l.utm).toEqual({ source: "youtube", medium: null, campaign: null, content: null });
|
||||||
|
expect(l.startedAt).toBe(1000);
|
||||||
|
expect(l.sendResult).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("v1 (опубликованный бот) проходит как раньше", () => {
|
||||||
|
const l = v1();
|
||||||
|
expect(l.archetype).toBe("gardener");
|
||||||
|
expect(l.score).toBe(70);
|
||||||
|
expect(l.quizType).toBeNull();
|
||||||
|
expect(l.sendResult).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("отказ: чужой тип, неполный v1, мусор", () => {
|
||||||
|
expect(parseQuizLead({ email: "a@example.com", quiz_type: "t9" })).toBeNull();
|
||||||
|
expect(parseQuizLead({ email: "a@example.com", archetype: "gardener" })).toBeNull();
|
||||||
|
expect(parseQuizLead({ email: "a@example.com" })).toBeNull();
|
||||||
|
expect(parseQuizLead(null)).toBeNull();
|
||||||
|
expect(parseQuizLead("x")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("почта с кавычкой или пробелом не принимается — она уходит в SQL-фильтр Listmonk", () => {
|
||||||
|
expect(isPlausibleEmail("a@example.com")).toBe(true);
|
||||||
|
expect(isPlausibleEmail("laukhin+test@dementiy.com")).toBe(true);
|
||||||
|
expect(isPlausibleEmail("x'or'1'='1@example.com")).toBe(false);
|
||||||
|
expect(isPlausibleEmail("a b@example.com")).toBe(false);
|
||||||
|
expect(isPlausibleEmail("a@localhost")).toBe(false);
|
||||||
|
expect(isPlausibleEmail("a".repeat(65) + "@example.com")).toBe(false);
|
||||||
|
expect(parseQuizLead({ email: "x'@example.com", quiz_type: "t1-start" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("фильтр экранирует кавычку на всякий случай", () => {
|
||||||
|
expect(emailQuery("o'neil@example.com")).toBe("subscribers.email='o''neil@example.com'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quiz-lead: отсев ботов", () => {
|
||||||
|
it("быстрее 20 секунд — бот, медленнее — человек, без времени (v1) — не судим", () => {
|
||||||
|
const t = NOW.getTime();
|
||||||
|
expect(botReason(v2({ started_at: t - 5_000 }), t)).toMatch(/^too-fast/);
|
||||||
|
expect(botReason(v2({ started_at: t - 90_000 }), t)).toBeNull();
|
||||||
|
expect(botReason(v1(), t)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("лимит в окне: сверх лимита — отказ, после окна — снова можно", () => {
|
||||||
|
const lim = new SlidingWindowLimiter(2, 1000);
|
||||||
|
expect(lim.allow("a", 0)).toBe(true);
|
||||||
|
expect(lim.allow("a", 10)).toBe(true);
|
||||||
|
expect(lim.allow("a", 20)).toBe(false);
|
||||||
|
expect(lim.allow("b", 20)).toBe(true);
|
||||||
|
expect(lim.allow("a", 1011)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("письмо-результат — не чаще раза в сутки и только по просьбе бота", () => {
|
||||||
|
const l = v2({ send_result: true });
|
||||||
|
expect(shouldSendResult(l, {}, NOW)).toBe(true);
|
||||||
|
expect(shouldSendResult(l, { quiz_result_sent_at: "2026-11-20T01:00:00Z" }, NOW)).toBe(false);
|
||||||
|
expect(shouldSendResult(l, { quiz_result_sent_at: "2026-11-19T09:00:00Z" }, NOW)).toBe(true);
|
||||||
|
expect(shouldSendResult(v2(), {}, NOW)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quiz-lead: существующий подписчик — ничего чужого не трогаем", () => {
|
||||||
|
it("списки и их статусы остаются, квиз добавляется отдельным вызовом, preconfirm выключен", () => {
|
||||||
|
const plan = planExisting(pressBuyer(), v2(), QUIZ_LIST, NOW);
|
||||||
|
expect(plan.kind).toBe("update");
|
||||||
|
if (plan.kind !== "update") return;
|
||||||
|
expect(plan.addToList).toBe(true);
|
||||||
|
expect(plan.put.lists).toEqual([3, 58, 13, QUIZ_LIST]);
|
||||||
|
expect(plan.put.preconfirm_subscriptions).toBe(false);
|
||||||
|
expect(plan.put.status).toBe("enabled");
|
||||||
|
expect(plan.put.name).toBe("Читатель");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("атрибуты дописываются, первая метка прихода не перетирается", () => {
|
||||||
|
const plan = planExisting(pressBuyer(), v2({ quiz_answers: "ответы" }), QUIZ_LIST, NOW);
|
||||||
|
if (plan.kind !== "update") throw new Error("ожидали update");
|
||||||
|
expect(plan.put.attribs).toMatchObject({
|
||||||
|
course: "obsidian",
|
||||||
|
updates_until: "20270101",
|
||||||
|
utm_source: "first-touch",
|
||||||
|
quiz_type: "t3-ready",
|
||||||
|
quiz_date: "20261120",
|
||||||
|
quiz_answers: "ответы",
|
||||||
|
quiz_utm: { source: "youtube", medium: null, campaign: null, content: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("уже в списке квиза (даже отписан) — заново не добавляем", () => {
|
||||||
|
const sub = pressBuyer({ lists: [{ id: 3 }, { id: QUIZ_LIST, subscription_status: "unsubscribed" }] });
|
||||||
|
const plan = planExisting(sub, v2(), QUIZ_LIST, NOW);
|
||||||
|
if (plan.kind !== "update") throw new Error("ожидали update");
|
||||||
|
expect(plan.addToList).toBe(false);
|
||||||
|
expect(plan.put.lists).toEqual([3, QUIZ_LIST]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("заблокированного не трогаем и не разблокируем", () => {
|
||||||
|
expect(planExisting(pressBuyer({ status: "blocklisted" }), v2(), QUIZ_LIST, NOW)).toEqual({
|
||||||
|
kind: "skip",
|
||||||
|
reason: "blocklisted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("v1 пишет archetype и score, общий utm — только если его не было", () => {
|
||||||
|
const fresh = mergeAttribs({}, v1(), NOW, false);
|
||||||
|
expect(fresh).toEqual({ archetype: "gardener", score: 70, utm_source: "telegram" });
|
||||||
|
const kept = mergeAttribs({ utm_source: "press" }, v1(), NOW, false);
|
||||||
|
expect(kept.utm_source).toBe("press");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("отметка об отправке письма ставится только когда письмо уходит", () => {
|
||||||
|
const plan = planExisting(pressBuyer(), v2({ send_result: true }), QUIZ_LIST, NOW);
|
||||||
|
if (plan.kind !== "update") throw new Error("ожидали update");
|
||||||
|
expect(plan.sendResult).toBe(true);
|
||||||
|
expect(plan.put.attribs.quiz_result_sent_at).toBe(NOW.toISOString());
|
||||||
|
const quiet = planExisting(pressBuyer(), v2(), QUIZ_LIST, NOW);
|
||||||
|
if (quiet.kind !== "update") throw new Error("ожидали update");
|
||||||
|
expect(quiet.put.attribs.quiz_result_sent_at).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("дата квиза — по Алматы", () => {
|
||||||
|
expect(almatyDate(new Date("2026-11-19T20:30:00Z"))).toBe("20261120");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Приём лида из квиза Typebot (`/api/internal/quiz-lead`) — чистая логика.
|
||||||
|
*
|
||||||
|
* Почему отдельный модуль (20261107, квиз v2): прежний обработчик для
|
||||||
|
* существующего подписчика делал `PUT /api/subscribers/{id}` с `lists: [48]`
|
||||||
|
* и новыми `attribs`. Listmonk заменяет этим вызовом списки и атрибуты
|
||||||
|
* ЦЕЛИКОМ — подписчик Press, прошедший квиз, выпадал из Press, у покупателя
|
||||||
|
* пропадали `course` и `updates_until`, а `status: "enabled"` снимал
|
||||||
|
* блокировку с бот-адресов. Проверено опытом на Listmonk 6.2 20261107.
|
||||||
|
*
|
||||||
|
* Правильный путь (тот же, что `payment-router/app/lite_subscribe.py`):
|
||||||
|
* 1. в список квиза — только `PUT /api/subscribers/lists` (action add),
|
||||||
|
* другие подписки не трогаются;
|
||||||
|
* 2. атрибуты — `PUT /api/subscribers/{id}` с ТЕКУЩИМИ списками, текущим
|
||||||
|
* статусом и `preconfirm_subscriptions: false` (с `true` Listmonk
|
||||||
|
* подтверждает все неподтверждённые подписки человека — карточка SBT
|
||||||
|
* `listmonk.md`, кейс 20260927);
|
||||||
|
* 3. заблокированного не трогаем вовсе.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const QUIZ_TYPES = ["t1-start", "t2-archive", "t3-ready", "t4-ai", "t5-team"] as const;
|
||||||
|
export type QuizType = (typeof QUIZ_TYPES)[number];
|
||||||
|
|
||||||
|
/** Архетипы квиза v1 — бот v1 ещё опубликован, его вызовы должны проходить. */
|
||||||
|
export const LEGACY_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"] as const;
|
||||||
|
export type LegacyArchetype = (typeof LEGACY_ARCHETYPES)[number];
|
||||||
|
|
||||||
|
/** Человек не проходит 10 вопросов быстрее — быстрее отвечает только скрипт. */
|
||||||
|
export const MIN_QUIZ_SECONDS = 20;
|
||||||
|
/** Письмо-результат — не чаще раза в сутки на адрес. */
|
||||||
|
export const RESULT_RESEND_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const MAX_ANSWERS_LEN = 2000;
|
||||||
|
const MAX_UTM_LEN = 200;
|
||||||
|
|
||||||
|
export type QuizUtm = { source: string | null; medium: string | null; campaign: string | null; content: string | null };
|
||||||
|
|
||||||
|
export type QuizLead = {
|
||||||
|
email: string;
|
||||||
|
/** v2: тип по проблеме; v1: архетип + «зрелость». Ровно одно из двух. */
|
||||||
|
quizType: QuizType | null;
|
||||||
|
archetype: LegacyArchetype | null;
|
||||||
|
score: number | null;
|
||||||
|
answers: string | null;
|
||||||
|
utm: QuizUtm;
|
||||||
|
/** Время старта квиза, мс с эпохи — для отсева скриптов. v1 его не шлёт. */
|
||||||
|
startedAt: number | null;
|
||||||
|
/** Просит ли бот отправить письмо-результат через LMS (v2). */
|
||||||
|
sendResult: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Без кавычек, пробелов и угловых скобок: адрес уходит в SQL-фильтр Listmonk.
|
||||||
|
const EMAIL_RE = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,24}$/;
|
||||||
|
|
||||||
|
export function isPlausibleEmail(email: string): boolean {
|
||||||
|
return email.length <= 254 && EMAIL_RE.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(v: unknown, max: number): string | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const s = String(v).trim();
|
||||||
|
if (!s || s === "null" || s === "undefined") return null;
|
||||||
|
// Неподставленная переменная Typebot приходит как «{{utm_source}}».
|
||||||
|
if (s.startsWith("{{") && s.endsWith("}}")) return null;
|
||||||
|
return s.slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseQuizLead(body: unknown): QuizLead | null {
|
||||||
|
if (!body || typeof body !== "object") return null;
|
||||||
|
const b = body as Record<string, unknown>;
|
||||||
|
const email = String(b.email ?? "").trim().toLowerCase();
|
||||||
|
if (!isPlausibleEmail(email)) return null;
|
||||||
|
|
||||||
|
const rawType = str(b.quiz_type, 32);
|
||||||
|
const quizType = rawType && (QUIZ_TYPES as readonly string[]).includes(rawType) ? (rawType as QuizType) : null;
|
||||||
|
const rawArch = str(b.archetype, 32);
|
||||||
|
const archetype =
|
||||||
|
rawArch && (LEGACY_ARCHETYPES as readonly string[]).includes(rawArch) ? (rawArch as LegacyArchetype) : null;
|
||||||
|
const scoreNum = b.score == null || b.score === "" ? NaN : Number(b.score);
|
||||||
|
const score = Number.isFinite(scoreNum) ? scoreNum : null;
|
||||||
|
|
||||||
|
if (rawType != null && !quizType) return null; // прислали тип, но не наш
|
||||||
|
if (!quizType && !(archetype && score != null)) return null; // ни v2, ни полного v1
|
||||||
|
|
||||||
|
const startedNum = Number(b.started_at);
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
quizType,
|
||||||
|
archetype: quizType ? null : archetype,
|
||||||
|
score: quizType ? null : score,
|
||||||
|
answers: str(b.quiz_answers, MAX_ANSWERS_LEN),
|
||||||
|
utm: {
|
||||||
|
source: str(b.utm_source, MAX_UTM_LEN),
|
||||||
|
medium: str(b.utm_medium, MAX_UTM_LEN),
|
||||||
|
campaign: str(b.utm_campaign, MAX_UTM_LEN),
|
||||||
|
content: str(b.utm_content, MAX_UTM_LEN),
|
||||||
|
},
|
||||||
|
startedAt: Number.isFinite(startedNum) && startedNum > 0 ? startedNum : null,
|
||||||
|
sendResult: b.send_result === true || b.send_result === "true",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Причина считать вызов ботом или null. Бота не выдаём ответом — молча пропускаем. */
|
||||||
|
export function botReason(lead: QuizLead, nowMs: number): string | null {
|
||||||
|
if (lead.startedAt != null) {
|
||||||
|
const sec = (nowMs - lead.startedAt) / 1000;
|
||||||
|
if (sec >= 0 && sec < MIN_QUIZ_SECONDS) return `too-fast:${sec.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYYMMDD по Алматы — `quiz_date` для писем дня 2/5/9. */
|
||||||
|
export function almatyDate(now: Date): string {
|
||||||
|
const d = new Date(now.getTime() + 5 * 60 * 60 * 1000);
|
||||||
|
return d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атрибуты после квиза: существующие сохраняются, квизовые перезаписываются,
|
||||||
|
* общие `utm_*` — только если их ещё нет (это метка ПЕРВОГО прихода, её
|
||||||
|
* нельзя перетирать повторным квизом).
|
||||||
|
*/
|
||||||
|
export function mergeAttribs(
|
||||||
|
existing: Record<string, unknown> | null | undefined,
|
||||||
|
lead: QuizLead,
|
||||||
|
now: Date,
|
||||||
|
resultSent: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...(existing ?? {}) };
|
||||||
|
if (lead.quizType) {
|
||||||
|
out.quiz_type = lead.quizType;
|
||||||
|
out.quiz_date = almatyDate(now);
|
||||||
|
if (lead.answers) out.quiz_answers = lead.answers;
|
||||||
|
out.quiz_utm = { ...lead.utm };
|
||||||
|
} else {
|
||||||
|
out.archetype = lead.archetype;
|
||||||
|
out.score = lead.score;
|
||||||
|
}
|
||||||
|
const firstTouch: Array<[string, string | null]> = [
|
||||||
|
["utm_source", lead.utm.source],
|
||||||
|
["utm_medium", lead.utm.medium],
|
||||||
|
["utm_campaign", lead.utm.campaign],
|
||||||
|
];
|
||||||
|
for (const [k, v] of firstTouch) {
|
||||||
|
if (v != null && (out[k] == null || out[k] === "")) out[k] = v;
|
||||||
|
}
|
||||||
|
if (resultSent) out.quiz_result_sent_at = now.toISOString();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListmonkSubscriber = {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
status: string; // enabled | disabled | blocklisted
|
||||||
|
lists: Array<{ id: number; subscription_status?: string }>;
|
||||||
|
attribs: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shouldSendResult(lead: QuizLead, attribs: Record<string, unknown> | null | undefined, now: Date): boolean {
|
||||||
|
if (!lead.sendResult) return false;
|
||||||
|
const last = attribs?.quiz_result_sent_at;
|
||||||
|
if (typeof last === "string") {
|
||||||
|
const t = Date.parse(last);
|
||||||
|
if (Number.isFinite(t) && now.getTime() - t < RESULT_RESEND_MS) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExistingPlan =
|
||||||
|
| { kind: "skip"; reason: "blocklisted" }
|
||||||
|
| {
|
||||||
|
kind: "update";
|
||||||
|
/** Нужно ли добавить в список квиза отдельным `PUT /api/subscribers/lists`. */
|
||||||
|
addToList: boolean;
|
||||||
|
sendResult: boolean;
|
||||||
|
/** Тело `PUT /api/subscribers/{id}` — только атрибуты, списки и статус как есть. */
|
||||||
|
put: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
lists: number[];
|
||||||
|
attribs: Record<string, unknown>;
|
||||||
|
preconfirm_subscriptions: false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function planExisting(sub: ListmonkSubscriber, lead: QuizLead, listId: number, now: Date): ExistingPlan {
|
||||||
|
if (sub.status === "blocklisted") return { kind: "skip", reason: "blocklisted" };
|
||||||
|
const current = sub.lists.map((l) => l.id);
|
||||||
|
// Любая запись о списке квиза — даже «отписан» — значит, добавлять не нужно:
|
||||||
|
// `action: add` поверх отписки вернул бы человека в список против его воли.
|
||||||
|
const addToList = !current.includes(listId);
|
||||||
|
const sendResult = shouldSendResult(lead, sub.attribs, now);
|
||||||
|
return {
|
||||||
|
kind: "update",
|
||||||
|
addToList,
|
||||||
|
sendResult,
|
||||||
|
put: {
|
||||||
|
email: sub.email,
|
||||||
|
name: sub.name || lead.email.split("@")[0],
|
||||||
|
status: sub.status,
|
||||||
|
lists: addToList ? [...current, listId] : current,
|
||||||
|
attribs: mergeAttribs(sub.attribs, lead, now, sendResult),
|
||||||
|
preconfirm_subscriptions: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Значение для SQL-фильтра Listmonk `subscribers.email='…'`. */
|
||||||
|
export function emailQuery(email: string): string {
|
||||||
|
return `subscribers.email='${email.replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скользящее окно в памяти процесса: контейнер приложения один, рестарт окно сбрасывает — это допустимо. */
|
||||||
|
export class SlidingWindowLimiter {
|
||||||
|
private hits = new Map<string, number[]>();
|
||||||
|
constructor(
|
||||||
|
private readonly limit: number,
|
||||||
|
private readonly windowMs: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
allow(key: string, nowMs: number): boolean {
|
||||||
|
const from = nowMs - this.windowMs;
|
||||||
|
const list = (this.hits.get(key) ?? []).filter((t) => t > from);
|
||||||
|
if (list.length >= this.limit) {
|
||||||
|
this.hits.set(key, list);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
list.push(nowMs);
|
||||||
|
this.hits.set(key, list);
|
||||||
|
if (this.hits.size > 10_000) this.prune(from);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private prune(from: number) {
|
||||||
|
for (const [k, v] of this.hits) {
|
||||||
|
const kept = v.filter((t) => t > from);
|
||||||
|
if (kept.length) this.hits.set(k, kept);
|
||||||
|
else this.hits.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user