Files
lms-sb/src/app/admin/import-export/import-actions.ts
T
admins 92fd88964e Accept buyer phone in grant API and enrich existing users
payment-router now forwards name/phone from the order. Store phone on
create; for existing users fill name (when it is an email placeholder)
and phone (when empty) without overwriting meaningful data. Also stop
dropping the parsed phone column in CSV import.
2026-07-11 19:41:49 +05:00

268 lines
8.3 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,
phone: row.phone || null,
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) {
const existing = await prisma.user.findUnique({
where: { id: row.existingId },
select: { phone: true },
});
await prisma.user.update({
where: { id: row.existingId },
data: {
name: row.name || undefined,
// don't overwrite an already-filled phone
phone: existing?.phone ? undefined : row.phone || 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 };
}