7bf3935c81
- User.archetype/utmSource/utmMedium/utmCampaign fields + migration - register flow reads ?archetype/?utm_* and saves them on the User row - dashboard ArchetypeBanner: per-archetype 'твой путь' block - lesson gate after wow-moment lesson (WOW_MOMENT_LESSON_ID=obs-start-l2-006) → tripwire + full-course offer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
4.9 KiB
TypeScript
140 lines
4.9 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
import { auth } from "@/lib/auth";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const disposableDomains = require("disposable-email-domains") as string[];
|
|
|
|
async function verifyTurnstile(token: string): Promise<boolean> {
|
|
try {
|
|
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
secret: process.env.TURNSTILE_SECRET_KEY,
|
|
response: token,
|
|
}),
|
|
});
|
|
const data = await res.json() as { success: boolean };
|
|
return data.success;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function jsonError(message: string, status: number, code?: string) {
|
|
return new Response(JSON.stringify({ message, code }), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
let body: Record<string, string>;
|
|
try {
|
|
body = await request.json() as Record<string, string>;
|
|
} catch {
|
|
return jsonError("Неверный формат запроса", 400);
|
|
}
|
|
|
|
const { name, email, password, website, cfTurnstileResponse, archetype, utmSource, utmMedium, utmCampaign } = body;
|
|
|
|
// Honeypot — bots fill this field, humans don't see it
|
|
if (website) {
|
|
return jsonError("Registration failed", 400);
|
|
}
|
|
|
|
// Disposable email check
|
|
const domain = email?.split("@")[1]?.toLowerCase();
|
|
if (domain && disposableDomains.includes(domain)) {
|
|
return jsonError("Пожалуйста, используйте постоянный email-адрес", 400);
|
|
}
|
|
|
|
// Turnstile check
|
|
if (process.env.TURNSTILE_SECRET_KEY) {
|
|
if (!cfTurnstileResponse) {
|
|
return jsonError("Пожалуйста, подтвердите, что вы не робот", 400);
|
|
}
|
|
if (!(await verifyTurnstile(cfTurnstileResponse))) {
|
|
return jsonError("Проверка капчи не пройдена. Попробуйте ещё раз", 400);
|
|
}
|
|
}
|
|
|
|
// Email уже зарегистрирован? Сообщаем явно, а не молчаливым «успехом»
|
|
// от Better Auth — частый случай после миграции (у людей уже есть аккаунт).
|
|
if (email) {
|
|
const existing = await prisma.user.findFirst({
|
|
where: { email: { equals: email, mode: "insensitive" } },
|
|
select: { id: true },
|
|
});
|
|
if (existing) {
|
|
return jsonError("Аккаунт с таким email уже зарегистрирован", 409, "EMAIL_TAKEN");
|
|
}
|
|
}
|
|
|
|
const baseUrl =
|
|
process.env.BETTER_AUTH_URL ??
|
|
`https://${request.headers.get("host")}`;
|
|
|
|
const response = await auth.handler(
|
|
new Request(`${baseUrl}/api/auth/sign-up/email`, {
|
|
method: "POST",
|
|
headers: new Headers({
|
|
"Content-Type": "application/json",
|
|
Origin: baseUrl,
|
|
}),
|
|
body: JSON.stringify({ name, email, password, callbackURL: "/dashboard" }),
|
|
})
|
|
);
|
|
|
|
// Freemium: при успешной регистрации авто-записываем в бесплатный курс.
|
|
// Не валим регистрацию, если enroll не удался.
|
|
if (response.ok && email) {
|
|
try {
|
|
const freeSlug = process.env.FREE_COURSE_SLUG ?? "obsidian-start";
|
|
const [user, course] = await Promise.all([
|
|
prisma.user.findFirst({
|
|
where: { email: { equals: email, mode: "insensitive" } },
|
|
select: { id: true },
|
|
}),
|
|
prisma.course.findUnique({
|
|
where: { slug: freeSlug },
|
|
select: { id: true },
|
|
}),
|
|
]);
|
|
// Тип второго мозга из квиза + UTM — сохраняем на юзере для персонализации
|
|
const VALID_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"];
|
|
if (user && (archetype || utmSource || utmMedium || utmCampaign)) {
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
archetype: VALID_ARCHETYPES.includes(String(archetype)) ? String(archetype) : null,
|
|
utmSource: utmSource ? String(utmSource) : null,
|
|
utmMedium: utmMedium ? String(utmMedium) : null,
|
|
utmCampaign: utmCampaign ? String(utmCampaign) : null,
|
|
},
|
|
});
|
|
}
|
|
if (user && course) {
|
|
await prisma.courseEnrollment.upsert({
|
|
where: { userId_courseId: { userId: user.id, courseId: course.id } },
|
|
update: {},
|
|
create: { userId: user.id, courseId: course.id },
|
|
});
|
|
await prisma.accessLog.create({
|
|
data: {
|
|
courseId: course.id,
|
|
userId: user.id,
|
|
action: "granted",
|
|
method: "free-signup",
|
|
},
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("free-enroll failed", e);
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|