dd46a10c20
Import wizard (4 steps): - Upload CSV with UTF-8 or Windows-1251 (iconv-lite) decoding - Auto-detect columns: Email, Имя, Фамилия, Телефон - Preview table with per-row status: new / update / error - Options: auto-verify email, assign course + access days, send welcome email - Apply: creates users with bcrypt password + Account record, grants enrollments Export: - GET /api/admin/export-users with course filter + encoding selection - UTF-8 with BOM (works in all apps) or Windows-1251 (legacy Excel) - Fields: Email, Имя, Телефон, Дата регистрации, Курсы, Прогресс Navigation: added "Импорт / Экспорт" link to admin sidebar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
261 lines
8.0 KiB
TypeScript
261 lines
8.0 KiB
TypeScript
"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<string, number> {
|
||
const map: Record<string, number> = {};
|
||
const aliases: Record<string, string[]> = {
|
||
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<PreviewResult> {
|
||
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<ApplyResult> {
|
||
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 };
|
||
}
|