"use server"; import { headers } from "next/headers"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import bcrypt from "bcryptjs"; import iconv from "iconv-lite"; import { sendWelcomeEmail } from "@/lib/email"; // ── Types ───────────────────────────────────────────────────────────────────── export type ParsedRow = { index: number; email: string; name: string; lastName: string; phone: string; // resolved during preview status: "new" | "update" | "error"; errorMsg?: string; existingId?: string; }; export type PreviewResult = { rows: ParsedRow[]; countNew: number; countUpdate: number; countError: number; }; export type ImportOptions = { updateExisting: boolean; autoVerifyEmail: boolean; courseId?: string; accessDays: number; // 0 = unlimited sendWelcome: boolean; encoding: "utf8" | "win1251"; }; export type ApplyResult = { created: number; updated: number; errors: string[]; }; // ── Helpers ─────────────────────────────────────────────────────────────────── function parseCSVLine(line: string): string[] { const result: string[] = []; let current = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { if (inQuotes && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } } else if ((ch === "," || ch === ";") && !inQuotes) { result.push(current.trim()); current = ""; } else { current += ch; } } result.push(current.trim()); return result; } function normalizeHeaders(headers: string[]): Record { const map: Record = {}; const aliases: Record = { email: ["email", "e-mail", "почта", "login", "логин"], name: ["имя", "name", "firstname", "first_name", "имя пользователя"], lastName: ["фамилия", "lastname", "last_name", "surname"], phone: ["телефон", "phone", "tel", "мобильный"], }; headers.forEach((h, i) => { const lower = h.toLowerCase().replace(/[^a-zа-яё0-9]/gi, ""); for (const [field, aliasList] of Object.entries(aliases)) { if (aliasList.some((a) => lower.includes(a.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "")))) { if (!(field in map)) map[field] = i; } } }); return map; } async function assertAdmin() { const session = await auth.api.getSession({ headers: await headers() }); if (!session || session.user.role !== "admin") throw new Error("Нет доступа"); return session; } // ── Parse action ────────────────────────────────────────────────────────────── export async function parseCSV( base64: string, encoding: "utf8" | "win1251", updateExisting: boolean ): Promise { await assertAdmin(); // Decode bytes const buffer = Buffer.from(base64, "base64"); const text = encoding === "win1251" ? iconv.decode(buffer, "win1251") : buffer.toString("utf8"); // Split lines (handle \r\n and \n) const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const nonEmpty = lines.filter((l) => l.trim().length > 0); if (nonEmpty.length < 2) { return { rows: [], countNew: 0, countUpdate: 0, countError: 0 }; } const headerLine = parseCSVLine(nonEmpty[0]); const colMap = normalizeHeaders(headerLine); if (colMap.email === undefined) { throw new Error("Не найдена колонка Email. Проверьте заголовки CSV-файла."); } // Load existing emails for fast lookup const existingUsers = await prisma.user.findMany({ select: { id: true, email: true }, }); const existingByEmail = new Map(existingUsers.map((u) => [u.email.toLowerCase(), u.id])); const rows: ParsedRow[] = []; let countNew = 0, countUpdate = 0, countError = 0; for (let i = 1; i < nonEmpty.length; i++) { const cols = parseCSVLine(nonEmpty[i]); const email = (cols[colMap.email] ?? "").trim().toLowerCase(); const name = (cols[colMap.name ?? -1] ?? "").trim(); const lastName = (cols[colMap.lastName ?? -1] ?? "").trim(); const phone = (cols[colMap.phone ?? -1] ?? "").trim(); const row: ParsedRow = { index: i, email, name: [name, lastName].filter(Boolean).join(" ") || email.split("@")[0], lastName, phone, status: "new", }; if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { row.status = "error"; row.errorMsg = "Некорректный email"; countError++; } else if (existingByEmail.has(email)) { row.existingId = existingByEmail.get(email); if (updateExisting) { row.status = "update"; countUpdate++; } else { row.status = "error"; row.errorMsg = "Уже существует (обновление отключено)"; countError++; } } else { row.status = "new"; countNew++; } rows.push(row); } return { rows, countNew, countUpdate, countError }; } // ── Apply action ────────────────────────────────────────────────────────────── export async function applyImport( rows: ParsedRow[], options: ImportOptions ): Promise { await assertAdmin(); let created = 0, updated = 0; const errors: string[] = []; const validRows = rows.filter((r) => r.status !== "error"); for (const row of validRows) { try { if (row.status === "new") { // Generate a random password const rawPassword = Math.random().toString(36).slice(-10) + "A1!"; const hashedPassword = await bcrypt.hash(rawPassword, 10); const user = await prisma.user.create({ data: { name: row.name, email: row.email, emailVerified: options.autoVerifyEmail, role: "student", }, }); await prisma.account.create({ data: { userId: user.id, accountId: user.id, providerId: "credential", password: hashedPassword, }, }); if (options.courseId) { const expiresAt = options.accessDays > 0 ? new Date(Date.now() + options.accessDays * 86_400_000) : null; await prisma.courseEnrollment.upsert({ where: { userId_courseId: { userId: user.id, courseId: options.courseId } }, update: { expiresAt }, create: { userId: user.id, courseId: options.courseId, expiresAt }, }); } if (options.sendWelcome) { await sendWelcomeEmail(user.email, user.name).catch(() => {}); } created++; } else if (row.status === "update" && row.existingId) { await prisma.user.update({ where: { id: row.existingId }, data: { name: row.name || undefined, }, }); if (options.courseId) { const expiresAt = options.accessDays > 0 ? new Date(Date.now() + options.accessDays * 86_400_000) : null; await prisma.courseEnrollment.upsert({ where: { userId_courseId: { userId: row.existingId, courseId: options.courseId } }, update: { expiresAt }, create: { userId: row.existingId, courseId: options.courseId, expiresAt }, }); } updated++; } } catch (e) { errors.push(`${row.email}: ${e instanceof Error ? e.message : "Ошибка"}`); } } return { created, updated, errors }; }