Files
lms-sb/src/app/admin/import-export/import-actions.ts
T
admins dd46a10c20 Add CSV import/export for students (Stage 11)
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>
2026-04-08 12:51:43 +05:00

261 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 };
}