Author SHA1 Message Date
adminsandClaude Opus 5.5 42518b2137 Mark quiz-lead merge logic with decision D-BIZ-017
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:38:12 +05:00
adminsandClaude Opus 5.5 767a0d5a6e 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>
2026-11-07 14:17:54 +05:00
adminsandClaude Opus 5.5 651a115ab7 Регистрация: utm с лендинга из cookie sb_utm, если в адресе меток нет
Кнопки лендингов ведут на /register без меток, поэтому utmCampaign не был
записан ни у одного пользователя. Caddy кладёт строку запроса с utm_* в cookie
sb_utm на .second-brain.ru; страница регистрации берёт метки из адреса, а если
их нет — из cookie. Значения: [A-Za-z0-9._-], до 100 символов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:16:09 +05:00
adminsandClaude Opus 5.5 d8777a7a23 Кликабельные ссылки: история фидбека у куратора и цитаты в письмах
Куратор видел адрес в своём фидбеке мёртвым текстом — linkify там не было.
В письмах (фидбек, ответы на вопросы и комментарии) quote() только экранировал:
кликабельность зависела от почтовика. Теперь ссылки оформлены <a>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 14:48:59 +05:00
adminsandClaude Opus 5.5 4792df274e access-check: точное сравнение почты (без ILIKE-шаблонов), заблокированным — нет, секрет за постоянное время
По фоновой проверке безопасности: mode insensitive превращался в ILIKE,
и % / _ в адресе давали «доступ есть» за чужую почту; banned-пользователь
считался с доступом; секрет сравнивался обычным ===. Почты в базе — в нижнем
регистре (0 из 1426 с заглавными), вход нормализуется.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 15:06:06 +05:00
adminsandClaude Opus 5.5 83f34ea679 Внутренний метод /api/internal/access-check: есть ли у почты доступ к курсу
Для бота выдачи SB Access (payment-router): старый покупатель Second Brain
Vault оставляет почту на /tg/link, ключ входа в бота выдаётся, только если
доступ к курсу действительно есть. Тот же общий секрет, что у grant;
ответ только да/нет, почта без учёта регистра, истёкший доступ не считается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 15:03:24 +05:00
adminsandClaude Opus 5.5 0a5368577c баннер архетипа: кнопка «Демо-хранилище» → «Second Brain Vault»
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:55:12 +05:00
adminsandClaude Opus 5.5 82737ba188 ROADMAP: служебные учётки на проде — к удалению по решению владельца
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:05:54 +05:00
adminsandClaude Opus 5.5 9553d1e9be ROADMAP: бэклог — дефекты сброса и смены пароля (флаг, /change-password, Math.random)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:05:06 +05:00
16 changed files with 792 additions and 117 deletions
+5
View File
@@ -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 «ежедневные заметки») — после его завершения
# показываем лестницу-оффер (трипвайр + полный курс) # показываем лестницу-оффер (трипвайр + полный курс)
+8
View File
@@ -275,6 +275,14 @@
## Бэклог (после MVP) ## Бэклог (после MVP)
- **⚠️ Смена и сброс пароля — три дефекта** (найдено 20261005, когда владелец менял пароль админа):
1. `resetUserPassword` (`src/app/admin/users/[userId]/actions.ts`, ~стр. 82–99) заменяет пароль временным, но **не ставит `mustChangePassword: true`** — пользователя с временным паролем никто не заставляет его сменить.
2. Ученическая зона при `mustChangePassword` делает `redirect("/change-password")` (`src/app/(student)/layout.tsx`, ~стр. 23), а **страницы `/change-password` в `src/app` нет** — ученик с флагом попадёт на 404. Нужна страница (форма уже есть: `src/app/(student)/profile/change-password-form.tsx` + `changePasswordAction`, она же снимает флаг).
3. Временный пароль генерируется через `Math.random()` — не криптостойко; заменить на `crypto.randomInt` (node:crypto).
- Заодно: у админа/куратора нет смены пароля в админке — сейчас только через ученический `/profile` (работает, проверено 20261005). Добавить ссылку или форму в админ-профиль; рассмотреть принудительную смену и для админки.
- Приёмка: сброс → вход временным паролем → принудительная форма смены (и у ученика, и у админа) → флаг снят; тест на генерацию пароля.
- Гигиена прода: остались служебные учётки `student@second-brain.ru` (`student-seed-001`, сид 20260407) и `loadtest@second-brain.ru` (`loadtest-user-001`, 20260506). Пароль из сида к ним не подходит (проверено bcrypt 20261005), но учётки не нужны — удалить или заблокировать по решению владельца.
- **Миграция email-шаблонов на React Email 6 + Resend CLI 2.0** (Resend Launch Week 6, 24.04.2026): - **Миграция email-шаблонов на React Email 6 + Resend CLI 2.0** (Resend Launch Week 6, 24.04.2026):
- React Email 6: новые шаблоны для auth и ecommerce flows (welcome, password reset, purchase confirmation, course progress) — можно взять за основу вместо своих - React Email 6: новые шаблоны для auth и ecommerce flows (welcome, password reset, purchase confirmation, course progress) — можно взять за основу вместо своих
- Resend CLI 2.0: локальный preview и тестирование шаблонов (`resend send --local ...`), 50+ команд - Resend CLI 2.0: локальный preview и тестирование шаблонов (`resend send --local ...`), 50+ команд
+10 -3
View File
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { RegisterForm } from "./register-form"; import { RegisterForm } from "./register-form";
import { UTM_COOKIE, parseUtmCookie, pickUtm } from "@/lib/utm-cookie";
export default async function RegisterPage({ export default async function RegisterPage({
searchParams, searchParams,
@@ -15,6 +17,11 @@ export default async function RegisterPage({
const sp = await searchParams; const sp = await searchParams;
const str = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? ""; const str = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? "";
// Метки: из адреса, а если их нет — из cookie лендинга (src/lib/utm-cookie.ts).
const utm = pickUtm(
{ utmSource: str(sp.utm_source), utmMedium: str(sp.utm_medium), utmCampaign: str(sp.utm_campaign) },
parseUtmCookie((await cookies()).get(UTM_COOKIE)?.value),
);
return ( return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}> <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
@@ -34,9 +41,9 @@ export default async function RegisterPage({
termsUrl={settings.termsUrl} termsUrl={settings.termsUrl}
offerUrl={settings.offerUrl} offerUrl={settings.offerUrl}
archetype={str(sp.archetype)} archetype={str(sp.archetype)}
utmSource={str(sp.utm_source)} utmSource={utm.utmSource}
utmMedium={str(sp.utm_medium)} utmMedium={utm.utmMedium}
utmCampaign={str(sp.utm_campaign)} utmCampaign={utm.utmCampaign}
gclid={str(sp.gclid)} gclid={str(sp.gclid)}
/> />
</div> </div>
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { accessWhere, isAuthorized, parseAccessCheck } from "@/lib/access-check";
/**
* Internal: есть ли у почты действующий доступ к курсу.
* Auth: тот же общий секрет, что у /api/internal/grant (`x-internal-secret`).
*
* Body: { email, course_slug } → { ok: true, has_access: boolean }
*/
export async function POST(request: NextRequest) {
if (!isAuthorized(request.headers.get("x-internal-secret"), process.env.INTERNAL_GRANT_SECRET)) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 });
}
const input = parseAccessCheck(body);
if (!input) {
return NextResponse.json({ ok: false, error: "missing_fields", need: ["email", "course_slug"] }, { status: 400 });
}
const found = await prisma.courseEnrollment.findFirst({
where: accessWhere(input, new Date()),
select: { userId: true },
});
return NextResponse.json({ ok: true, has_access: Boolean(found) });
}
+110 -101
View File
@@ -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 });
@@ -229,7 +229,7 @@ export default async function SubmissionPage({ params }: Props) {
{new Date(fb.createdAt).toLocaleString("ru-RU")} {new Date(fb.createdAt).toLocaleString("ru-RU")}
</span> </span>
</div> </div>
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p> <p className="text-sm whitespace-pre-wrap mb-2">{linkify(fb.text)}</p>
{fbFiles.length > 0 && ( {fbFiles.length > 0 && (
<div className="space-y-1 mt-2"> <div className="space-y-1 mt-2">
{fbFiles.map((f) => ( {fbFiles.map((f) => (
+1 -1
View File
@@ -25,7 +25,7 @@ const ARCHETYPE_INFO: Record<
emoji: "📚", emoji: "📚",
name: "Библиотекарь", name: "Библиотекарь",
desc: "Ты собираешь и хранишь надёжно. Начни с готового хранилища и руководств.", desc: "Ты собираешь и хранишь надёжно. Начни с готового хранилища и руководств.",
cta: "Демо-хранилище", cta: "Second Brain Vault",
href: "https://second-brain.ru/sb-vault", href: "https://second-brain.ru/sb-vault",
}, },
pragmatist: { pragmatist: {
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { accessWhere, isAuthorized, parseAccessCheck } from "../access-check";
describe("access-check", () => {
it("нормализует почту и требует оба поля", () => {
expect(parseAccessCheck({ email: " A@Example.COM ", course_slug: "second-brain-vault" }))
.toEqual({ email: "a@example.com", courseSlug: "second-brain-vault" });
expect(parseAccessCheck({ email: "a@example.com" })).toBeNull();
expect(parseAccessCheck({ course_slug: "x" })).toBeNull();
expect(parseAccessCheck({ email: "без-собаки", course_slug: "x" })).toBeNull();
expect(parseAccessCheck(null)).toBeNull();
expect(parseAccessCheck({ email: "a".repeat(250) + "@b.ru", course_slug: "x" })).toBeNull();
});
it("условие: точная почта (без ILIKE-шаблонов), не заблокирован, курс по slug, доступ не истёк", () => {
const now = new Date("2026-10-13T00:00:00Z");
const w = accessWhere({ email: "a@example.com", courseSlug: "second-brain-vault" }, now);
expect(w.user.email).toBe("a@example.com");
expect(JSON.stringify(w)).not.toContain("insensitive");
expect(w.user.NOT).toEqual({ banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] });
expect(w.course).toEqual({ slug: "second-brain-vault" });
expect(w.OR).toEqual([{ expiresAt: null }, { expiresAt: { gt: now } }]);
});
it("без секрета или с чужим — отказ", () => {
expect(isAuthorized("s", "s")).toBe(true);
expect(isAuthorized("s", undefined)).toBe(false);
expect(isAuthorized("s", "")).toBe(false);
expect(isAuthorized(null, "s")).toBe(false);
expect(isAuthorized("x", "s")).toBe(false);
expect(isAuthorized("ss", "s")).toBe(false);
});
});
@@ -83,4 +83,20 @@ describe("sendCommentReplyEmail", () => {
expect(html).not.toContain("Отвечает <strong>"); expect(html).not.toContain("Отвечает <strong>");
expect(html).toContain("Привет!"); expect(html).toContain("Привет!");
}); });
it("делает ссылки в тексте ответа кликабельными", async () => {
await sendCommentReplyEmail(
"student@example.com",
"Екатерина",
"6.3. Холст",
"Подарок тут: https://obsidian.second-brain.ru/secret-bonus?a=1&b=2. Пишите!",
LESSON_URL
);
const html = sent[0].html;
expect(html).toContain(
'<a href="https://obsidian.second-brain.ru/secret-bonus?a=1&amp;b=2" target="_blank"'
);
expect(html).toContain("secret-bonus?a=1&amp;b=2</a>. Пишите!");
});
}); });
+169
View File
@@ -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");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { cleanUtm, parseUtmCookie, pickUtm } from "../utm-cookie";
describe("utm-cookie", () => {
it("разбирает сырую строку запроса из cookie", () => {
expect(parseUtmCookie("utm_source=youtube&utm_medium=video&utm_campaign=test-20261103&fbclid=x")).toEqual({
utmSource: "youtube",
utmMedium: "video",
utmCampaign: "test-20261103",
});
});
it("понимает percent-кодирование", () => {
expect(parseUtmCookie("utm_campaign=a%2Db_c.d").utmCampaign).toBe("a-b_c.d");
});
it("пустая и битая cookie — пустые метки", () => {
expect(parseUtmCookie(undefined)).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
expect(parseUtmCookie("")).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
expect(parseUtmCookie("%%%")).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
});
it("отбрасывает значения не из белого списка символов и слишком длинные", () => {
expect(cleanUtm("ok-1")).toBe("ok-1");
expect(cleanUtm("<script>")).toBe("");
expect(cleanUtm("с пробелом")).toBe("");
expect(cleanUtm("a".repeat(150))).toBe("a".repeat(100));
expect(parseUtmCookie("utm_source=x;Domain=evil").utmSource).toBe("");
});
it("метки из адреса важнее cookie, поля не смешиваются", () => {
const cookie = { utmSource: "vk", utmMedium: "video", utmCampaign: "old" };
expect(pickUtm({ utmSource: "", utmMedium: "", utmCampaign: "new" }, cookie)).toEqual({
utmSource: "",
utmMedium: "",
utmCampaign: "new",
});
expect(pickUtm({ utmSource: "", utmMedium: "", utmCampaign: "" }, cookie)).toEqual(cookie);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Проверка «у этой почты есть действующий доступ к курсу» для внутренних
* вызовов (бот выдачи SB Access в payment-router, 20261013): старый покупатель
* Second Brain Vault оставляет почту, бот получает ключ входа, только если
* доступ к курсу действительно есть.
*
* Ответ — только да/нет: ни имени, ни дат наружу не отдаём.
*/
import { timingSafeEqual } from "crypto";
export type AccessCheckInput = { email: string; courseSlug: string };
export function parseAccessCheck(body: unknown): AccessCheckInput | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
const courseSlug = typeof b.course_slug === "string" ? b.course_slug.trim() : "";
if (!email || email.length > 254 || !email.includes("@") || !courseSlug || courseSlug.length > 100) {
return null;
}
return { email, courseSlug };
}
/**
* Условие Prisma: не истёкшая запись о доступе к курсу у незаблокированного
* пользователя с ЭТОЙ почтой.
*
* Почту сравниваем точно, без `mode: "insensitive"`: Prisma превращает такое
* сравнение в ILIKE, и `%`/`_` в адресе становятся шаблоном — по адресу-маске
* метод отвечал бы «доступ есть» за чужую почту (проверка безопасности
* 20261013). Все почты в базе хранятся в нижнем регистре (проверено на проде:
* 0 из 1426 с заглавными), вход нормализуется parseAccessCheck.
*/
export function accessWhere(input: AccessCheckInput, now: Date) {
return {
user: {
email: input.email,
NOT: { banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] },
},
course: { slug: input.courseSlug },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
};
}
/** Секрет сравниваем за постоянное время: обычное === выдаёт длину совпавшего префикса по времени. */
export function isAuthorized(header: string | null, secret: string | undefined): boolean {
if (!secret || !header) return false;
const a = Buffer.from(header);
const b = Buffer.from(secret);
return a.length === b.length && timingSafeEqual(a, b);
}
+15 -1
View File
@@ -1,5 +1,6 @@
import { Resend } from "resend"; import { Resend } from "resend";
import { getSetting } from "./settings"; import { getSetting } from "./settings";
import { URL_RE } from "./linkify";
function getResend() { function getResend() {
return new Resend(process.env.RESEND_API_KEY); return new Resend(process.env.RESEND_API_KEY);
@@ -97,11 +98,24 @@ function escapeHtml(text: string) {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
// Ссылки в цитате — кликабельные: не все почтовики сами превращают голый адрес в ссылку.
// Экранируем по частям, чтобы `&` в адресе не превратился в `&amp;` внутри href дважды.
function linkifyHtml(text: string) {
return text
.split(URL_RE)
.map((part, i) =>
i % 2 === 1
? `<a href="${escapeHtml(part)}" target="_blank" style="color:#323232;text-decoration:underline;word-break:break-all;">${escapeHtml(part)}</a>`
: escapeHtml(part)
)
.join("");
}
function quote(text: string) { function quote(text: string) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;"> return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
<tr> <tr>
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;"> <td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${escapeHtml(text).replace(/\n/g, "<br/>")}</p> <p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${linkifyHtml(text).replace(/\n/g, "<br/>")}</p>
</td> </td>
</tr> </tr>
</table>`; </table>`;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Fragment, type ReactNode } from "react"; import { Fragment, type ReactNode } from "react";
const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g; export const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g;
export function linkify(text: string): ReactNode[] { export function linkify(text: string): ReactNode[] {
if (!text) return []; if (!text) return [];
+244
View File
@@ -0,0 +1,244 @@
/**
* Приём лида из квиза 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;
};
};
// РЕШЕНИЕ D-BIZ-017: список — через subscribers/lists add, атрибуты — PUT с текущими списками без preconfirm
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);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
// Метки utm с лендингов (20261103, задание CMO «utm_campaign в LMS»).
//
// Лендинги второго мозга — поддомены second-brain.ru за общим Caddy на проде.
// Caddy при заходе с utm_* в адресе кладёт сырую строку запроса в cookie
// `sb_utm` на домен .second-brain.ru (см. фрагмент `(sb_utm)` в prod.caddy).
// Кнопка лендинга ведёт на /register без меток, поэтому страница регистрации
// берёт их из адреса, а если там пусто — из этой cookie.
export const UTM_COOKIE = "sb_utm";
export type Utm = { utmSource: string; utmMedium: string; utmCampaign: string };
const EMPTY: Utm = { utmSource: "", utmMedium: "", utmCampaign: "" };
// Значение метки: только латиница, цифры, точка, дефис, подчёркивание; до 100 символов.
// Остальное (кириллица, пробелы, кавычки) отбрасываем целиком — в отчёт идут слаги.
export function cleanUtm(v: string | null | undefined): string {
if (!v) return "";
const s = v.trim().slice(0, 100);
return /^[A-Za-z0-9._-]+$/.test(s) ? s : "";
}
export function parseUtmCookie(raw: string | null | undefined): Utm {
if (!raw) return EMPTY;
let q: URLSearchParams;
try {
q = new URLSearchParams(raw.length > 2000 ? raw.slice(0, 2000) : raw);
} catch {
return EMPTY;
}
return {
utmSource: cleanUtm(q.get("utm_source")),
utmMedium: cleanUtm(q.get("utm_medium")),
utmCampaign: cleanUtm(q.get("utm_campaign")),
};
}
// Метки из адреса важнее cookie: прямой переход на /register?utm_… — самый свежий.
// Берём набор целиком из одного источника, не смешиваем поля.
export function pickUtm(fromQuery: Utm, fromCookie: Utm): Utm {
const q = {
utmSource: cleanUtm(fromQuery.utmSource),
utmMedium: cleanUtm(fromQuery.utmMedium),
utmCampaign: cleanUtm(fromQuery.utmCampaign),
};
if (q.utmSource || q.utmMedium || q.utmCampaign) return q;
return fromCookie;
}