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:
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user