From 767a0d5a6e6826ea9bff8b1ead3e31ec69267509 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Sat, 7 Nov 2026 14:17:54 +0500 Subject: [PATCH] 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 --- .env.example | 5 + src/app/api/internal/quiz-lead/route.ts | 229 +++++++++++----------- src/lib/__tests__/quiz-lead.test.ts | 169 ++++++++++++++++ src/lib/quiz-lead.ts | 243 ++++++++++++++++++++++++ 4 files changed, 536 insertions(+), 110 deletions(-) create mode 100644 src/lib/__tests__/quiz-lead.test.ts create mode 100644 src/lib/quiz-lead.ts diff --git a/.env.example b/.env.example index f0fe315..b803662 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,11 @@ LISTMONK_URL="https://newsletter.second-brain.ru" LISTMONK_USER="apiuser" LISTMONK_TOKEN="" LISTMONK_QUIZ_LIST_ID="" +# Квиз v2: письмо-результат через LMS (tx-шаблон с ветками по quiz_type); пусто — LMS писем не шлёт +LISTMONK_QUIZ_TX_TEMPLATE_ID="" +QUIZ_RESULT_FROM="Second Brain " +# Защита от ботов: потолок лидов квиза в час (по умолчанию 60) +QUIZ_LEAD_MAX_PER_HOUR="" # Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения # показываем лестницу-оффер (трипвайр + полный курс) diff --git a/src/app/api/internal/quiz-lead/route.ts b/src/app/api/internal/quiz-lead/route.ts index e7960f4..cff5ae0 100644 --- a/src/app/api/internal/quiz-lead/route.ts +++ b/src/app/api/internal/quiz-lead/route.ts @@ -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 { + 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 { + 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 { + 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)}`); } -/** - * 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 { - // --- 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 { return NextResponse.json({ error: "bad json" }, { status: 400 }); } - // --- Validate fields --- - const raw = body as Record; - 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 }); diff --git a/src/lib/__tests__/quiz-lead.test.ts b/src/lib/__tests__/quiz-lead.test.ts new file mode 100644 index 0000000..f2f5553 --- /dev/null +++ b/src/lib/__tests__/quiz-lead.test.ts @@ -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 = {}) => + 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 => ({ + 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"); + }); +}); diff --git a/src/lib/quiz-lead.ts b/src/lib/quiz-lead.ts new file mode 100644 index 0000000..7a832ea --- /dev/null +++ b/src/lib/quiz-lead.ts @@ -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; + 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 | null | undefined, + lead: QuizLead, + now: Date, + resultSent: boolean, +): Record { + const out: Record = { ...(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 | null; +}; + +export function shouldSendResult(lead: QuizLead, attribs: Record | 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; + 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(); + 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); + } + } +}