batch import csv
This commit is contained in:
parent
a679775f57
commit
dd0ceef665
20 changed files with 1614 additions and 190 deletions
177
frontend/src/utils/importUsersCsv.js
Normal file
177
frontend/src/utils/importUsersCsv.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
const SAMPLE_CSV = `First name*,Last name*,Work email*,Invitation email,Initial password
|
||||
Rahul,Sharma,rahul,rahul@gmail.com,Roadrentals123
|
||||
Anurag,Jangid,anurag.jangid,anurag.personal@gmail.com,Roadrentals123`;
|
||||
|
||||
const HEADER_ALIASES = {
|
||||
"first name": "first_name",
|
||||
"first name*": "first_name",
|
||||
"last name": "last_name",
|
||||
"last name*": "last_name",
|
||||
"work email": "work_email",
|
||||
"work email*": "work_email",
|
||||
"invitation email": "invitation_email",
|
||||
"initial password": "password",
|
||||
};
|
||||
|
||||
export const IMPORT_ROW_LIMIT = 300;
|
||||
|
||||
function normalizeHeader(value) {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const cells = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[index + 1] === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "," && !inQuotes) {
|
||||
cells.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function buildSampleCsvBlob() {
|
||||
return new Blob([SAMPLE_CSV], { type: "text/csv;charset=utf-8" });
|
||||
}
|
||||
|
||||
export function downloadSampleCsv() {
|
||||
const url = URL.createObjectURL(buildSampleCsvBlob());
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "agmail-people-import-sample.csv";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function parseUsersCsv(text) {
|
||||
const lines = text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error("CSV must include a header row and at least one person.");
|
||||
}
|
||||
|
||||
const headers = parseCsvLine(lines[0]).map(normalizeHeader);
|
||||
const columnMap = headers.map((header) => HEADER_ALIASES[header]);
|
||||
if (columnMap.some((column) => !column)) {
|
||||
throw new Error(
|
||||
"CSV headers must be: First name*, Last name*, Work email*, Invitation email, Initial password",
|
||||
);
|
||||
}
|
||||
|
||||
const rows = lines.slice(1).map((line, index) => {
|
||||
const cells = parseCsvLine(line);
|
||||
const row = {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
work_email: "",
|
||||
invitation_email: "",
|
||||
password: "",
|
||||
};
|
||||
columnMap.forEach((field, columnIndex) => {
|
||||
row[field] = cells[columnIndex]?.trim() || "";
|
||||
});
|
||||
return { rowNumber: index + 2, ...row };
|
||||
});
|
||||
|
||||
if (rows.length > IMPORT_ROW_LIMIT) {
|
||||
throw new Error(`You can import up to ${IMPORT_ROW_LIMIT} people at a time.`);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;
|
||||
|
||||
export function validateImportRows(rows, { companyDomain, sendInvitation }) {
|
||||
const errors = [];
|
||||
const seenEmails = new Set();
|
||||
const seenInvites = new Set();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const rowErrors = [];
|
||||
if (!row.first_name.trim()) rowErrors.push("First name is required.");
|
||||
if (!row.last_name.trim()) rowErrors.push("Last name is required.");
|
||||
|
||||
const workEmailRaw = row.work_email.trim().toLowerCase();
|
||||
const workEmail = workEmailRaw.includes("@")
|
||||
? workEmailRaw
|
||||
: `${workEmailRaw}@${companyDomain}`;
|
||||
const localPart = workEmail.split("@")[0];
|
||||
|
||||
if (!workEmailRaw) {
|
||||
rowErrors.push("Work email is required.");
|
||||
} else if (!workEmail.endsWith(`@${companyDomain}`)) {
|
||||
rowErrors.push(`Work email must use @${companyDomain}.`);
|
||||
} else if (!/^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/.test(localPart)) {
|
||||
rowErrors.push("Work email format is invalid.");
|
||||
} else if (seenEmails.has(workEmail)) {
|
||||
rowErrors.push("Duplicate work email in this file.");
|
||||
}
|
||||
seenEmails.add(workEmail);
|
||||
|
||||
const password = row.password.trim();
|
||||
if (!password) {
|
||||
rowErrors.push("Initial password is required.");
|
||||
} else if (!PASSWORD_PATTERN.test(password)) {
|
||||
rowErrors.push("Password must be at least 8 characters with upper, lower, and a number.");
|
||||
}
|
||||
|
||||
const invitationEmail = row.invitation_email.trim().toLowerCase();
|
||||
if (sendInvitation) {
|
||||
if (!invitationEmail) {
|
||||
rowErrors.push("Invitation email is required when sending invitations.");
|
||||
} else if (invitationEmail.endsWith(`@${companyDomain}`)) {
|
||||
rowErrors.push("Use a personal email for invitations.");
|
||||
} else if (seenInvites.has(invitationEmail)) {
|
||||
rowErrors.push("Duplicate invitation email in this file.");
|
||||
} else {
|
||||
seenInvites.add(invitationEmail);
|
||||
}
|
||||
}
|
||||
|
||||
if (rowErrors.length > 0) {
|
||||
errors.push({ row: row.rowNumber, email: workEmail, messages: rowErrors });
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function rowsToImportPayload(rows, options) {
|
||||
const { orgRole, sendInvitation, forcePasswordChange, companyDomain } = options;
|
||||
return {
|
||||
org_role: orgRole,
|
||||
send_invitation: sendInvitation,
|
||||
force_password_change: forcePasswordChange,
|
||||
rows: rows.map((row) => ({
|
||||
first_name: row.first_name.trim(),
|
||||
last_name: row.last_name.trim(),
|
||||
work_email: row.work_email.trim().toLowerCase().includes("@")
|
||||
? row.work_email.trim().toLowerCase()
|
||||
: `${row.work_email.trim().toLowerCase()}@${companyDomain}`,
|
||||
invitation_email: row.invitation_email.trim() || undefined,
|
||||
password: row.password.trim(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue