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>
This commit is contained in:
2026-04-08 12:51:43 +05:00
parent 99c143d670
commit dd46a10c20
8 changed files with 851 additions and 0 deletions
@@ -0,0 +1,260 @@
"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 };
}
+49
View File
@@ -0,0 +1,49 @@
import { prisma } from "@/lib/prisma";
import { CsvImporter } from "@/components/admin/csv-importer";
import { CsvExporter } from "@/components/admin/csv-exporter";
export const metadata = { title: "Импорт и экспорт" };
export default async function ImportExportPage() {
const courses = await prisma.course.findMany({
orderBy: { title: "asc" },
select: { id: true, title: true },
});
return (
<div className="p-8 max-w-3xl">
<div className="mb-8">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Импорт и экспорт
</h1>
</div>
<div className="space-y-6">
{/* Import */}
<div className="card-aubade p-6">
<p
className="text-xs font-bold uppercase tracking-widest mb-5"
style={{ color: "var(--muted-foreground)" }}
>
Импорт учеников из CSV
</p>
<CsvImporter courses={courses} />
</div>
{/* Export */}
<div className="card-aubade p-6">
<p
className="text-xs font-bold uppercase tracking-widest mb-5"
style={{ color: "var(--muted-foreground)" }}
>
Экспорт учеников в CSV
</p>
<CsvExporter courses={courses} />
</div>
</div>
</div>
);
}