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
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import iconv from "iconv-lite";
export async function GET(request: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Нет доступа" }, { status: 403 });
}
const { searchParams } = request.nextUrl;
const courseId = searchParams.get("courseId") || undefined;
const encoding = (searchParams.get("encoding") as "utf8" | "win1251") ?? "utf8";
// Fetch users
const users = await prisma.user.findMany({
where: courseId
? { enrollments: { some: { courseId } } }
: { role: "student" },
orderBy: { createdAt: "desc" },
include: {
enrollments: {
include: { course: { select: { title: true } } },
},
progress: { select: { lessonId: true } },
},
});
// Build CSV rows
const csvHeaders = ["Email", "Имя", "Телефон", "Дата регистрации", "Курсы", "Прогресс (уроков)"];
const rows = users.map((u) => {
const courses = u.enrollments.map((e) => e.course.title).join(" | ");
const progress = u.progress.length;
const registeredAt = new Date(u.createdAt).toLocaleDateString("ru-RU");
return [u.email, u.name, "", registeredAt, courses, String(progress)];
});
const allRows = [csvHeaders, ...rows];
const csvText = allRows
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(";"))
.join("\r\n");
// Encode
let body: Buffer;
let charset: string;
if (encoding === "win1251") {
body = iconv.encode(csvText, "win1251");
charset = "windows-1251";
} else {
body = Buffer.from("\uFEFF" + csvText, "utf8"); // BOM for Excel
charset = "utf-8";
}
const filename = `students_${new Date().toISOString().slice(0, 10)}.csv`;
return new NextResponse(body as unknown as BodyInit, {
headers: {
"Content-Type": `text/csv; charset=${charset}`,
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}