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.
268 lines
8.3 KiB
TypeScript
268 lines
8.3 KiB
TypeScript
"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 };
|
||
}
|