forked from baron/baron-sso
611 lines
16 KiB
TypeScript
611 lines
16 KiB
TypeScript
import type { TenantSummary } from "../../../lib/adminApi";
|
|
|
|
export type TenantCSVRow = {
|
|
rowNumber: number;
|
|
tenantId: string;
|
|
name: string;
|
|
type: string;
|
|
parentTenantId: string;
|
|
parentTenantSlug: string;
|
|
slug: string;
|
|
memo: string;
|
|
emailDomain: string;
|
|
visibility: string;
|
|
orgUnitType: string;
|
|
};
|
|
|
|
export type TenantCSVParseOptions = {
|
|
rootParentSlug?: string;
|
|
};
|
|
|
|
type TenantCSVSourceKey = keyof TenantCSVRow | "mailingList" | "parentOrg";
|
|
|
|
export type TenantImportCandidate = {
|
|
tenantId: string;
|
|
name: string;
|
|
slug: string;
|
|
score: number;
|
|
reason: "exact_name" | "exact_slug" | "similar_name";
|
|
};
|
|
|
|
export type TenantImportPreviewRow = {
|
|
row: TenantCSVRow;
|
|
candidates: TenantImportCandidate[];
|
|
defaultTenantId: string;
|
|
defaultCreateSlug: string;
|
|
conflicts: TenantImportConflict[];
|
|
};
|
|
|
|
export type TenantImportParentOptionGroupType =
|
|
| "COMPANY_GROUP"
|
|
| "COMPANY"
|
|
| "ORGANIZATION";
|
|
|
|
export type TenantImportParentOptionGroup = {
|
|
type: TenantImportParentOptionGroupType;
|
|
tenants: TenantSummary[];
|
|
};
|
|
|
|
export type TenantImportConflict =
|
|
| "external_tenant_id"
|
|
| "slug_exists"
|
|
| "parent_tenant_id_unresolved";
|
|
|
|
export type TenantImportResolution =
|
|
| {
|
|
mode: "existing";
|
|
tenantId: string;
|
|
parentTenantId?: string;
|
|
parentTenantSlug?: string;
|
|
}
|
|
| {
|
|
mode: "create";
|
|
tenantId?: string;
|
|
slug?: string;
|
|
parentTenantId?: string;
|
|
parentTenantSlug?: string;
|
|
}
|
|
| {
|
|
mode: "skip";
|
|
};
|
|
|
|
const importHeaders = [
|
|
"tenant_id",
|
|
"name",
|
|
"type",
|
|
"parent_tenant_id",
|
|
"parent_tenant_slug",
|
|
"slug",
|
|
"memo",
|
|
"email_domain",
|
|
"visibility",
|
|
"org_unit_type",
|
|
];
|
|
|
|
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
|
id: "tenantId",
|
|
tenantid: "tenantId",
|
|
tenant_id: "tenantId",
|
|
name: "name",
|
|
조직명: "name",
|
|
type: "type",
|
|
parentid: "parentTenantId",
|
|
parent_id: "parentTenantId",
|
|
parenttenantid: "parentTenantId",
|
|
parent_tenant_id: "parentTenantId",
|
|
parenttenantslug: "parentTenantSlug",
|
|
parent_tenant_slug: "parentTenantSlug",
|
|
상위_조직: "parentOrg",
|
|
slug: "slug",
|
|
memo: "memo",
|
|
description: "memo",
|
|
설명: "memo",
|
|
메일링_리스트: "mailingList",
|
|
"email-domain": "emailDomain",
|
|
emaildomain: "emailDomain",
|
|
email_domain: "emailDomain",
|
|
domain: "emailDomain",
|
|
domains: "emailDomain",
|
|
visibility: "visibility",
|
|
public_setting: "visibility",
|
|
publicsetting: "visibility",
|
|
orgunittype: "orgUnitType",
|
|
org_unit_type: "orgUnitType",
|
|
"org-unit-type": "orgUnitType",
|
|
organizationtype: "orgUnitType",
|
|
organization_type: "orgUnitType",
|
|
orgtype: "orgUnitType",
|
|
org_type: "orgUnitType",
|
|
};
|
|
|
|
export function parseTenantCSV(
|
|
text: string,
|
|
options: TenantCSVParseOptions = {},
|
|
): TenantCSVRow[] {
|
|
const records = parseCSV(text.replace(/^\uFEFF/, ""));
|
|
if (records.length === 0) return [];
|
|
|
|
const header = new Map<TenantCSVSourceKey, number>();
|
|
records[0].forEach((column, index) => {
|
|
const normalized = normalizeHeader(column);
|
|
const key = headerAliases[normalized];
|
|
if (key) header.set(key, index);
|
|
});
|
|
|
|
const isOrgChartCSV = header.has("mailingList") || header.has("parentOrg");
|
|
const sourceRows = records.slice(1).flatMap((record, index) => {
|
|
if (record.every((value) => value.trim() === "")) return [];
|
|
const value = (key: TenantCSVSourceKey) => {
|
|
const columnIndex = header.get(key);
|
|
if (columnIndex === undefined) return "";
|
|
return (record[columnIndex] ?? "").trim();
|
|
};
|
|
|
|
return {
|
|
raw: record,
|
|
rowNumber: index + 2,
|
|
name: value("name"),
|
|
slug: value("slug") || slugFromMailingList(value("mailingList")),
|
|
mailingList: value("mailingList"),
|
|
parentOrg: value("parentOrg"),
|
|
value,
|
|
};
|
|
});
|
|
const slugByName = new Map(
|
|
sourceRows
|
|
.filter((row) => row.name && row.slug)
|
|
.map((row) => [row.name, row.slug] as const),
|
|
);
|
|
|
|
return sourceRows.map(({ rowNumber, name, slug, parentOrg, value }) => {
|
|
const parentTenantSlug =
|
|
value("parentTenantSlug") ||
|
|
slugFromParentOrg(parentOrg, slugByName) ||
|
|
(isOrgChartCSV ? options.rootParentSlug || "" : "");
|
|
|
|
return {
|
|
rowNumber,
|
|
tenantId: value("tenantId"),
|
|
name,
|
|
type: value("type") || (isOrgChartCSV ? "ORGANIZATION" : ""),
|
|
parentTenantId: value("parentTenantId"),
|
|
parentTenantSlug,
|
|
slug,
|
|
memo: value("memo"),
|
|
emailDomain: value("emailDomain"),
|
|
visibility: value("visibility"),
|
|
orgUnitType: value("orgUnitType"),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function inferTenantImportRootParentSlug(
|
|
fileName: string,
|
|
tenants: TenantSummary[] = [],
|
|
) {
|
|
const baseName = fileName.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
|
const [prefix = ""] = baseName.split("_");
|
|
if (!prefix) return "";
|
|
|
|
const existingTenant = tenants.find(
|
|
(tenant) => tenant.slug.toLowerCase() === prefix,
|
|
);
|
|
return existingTenant ? prefix : "";
|
|
}
|
|
|
|
export function buildTenantImportParentOptionGroups(
|
|
tenants: TenantSummary[],
|
|
): TenantImportParentOptionGroup[] {
|
|
const orderedTypes: TenantImportParentOptionGroupType[] = [
|
|
"COMPANY_GROUP",
|
|
"COMPANY",
|
|
"ORGANIZATION",
|
|
];
|
|
|
|
return orderedTypes
|
|
.map((type) => ({
|
|
type,
|
|
tenants: tenants.filter((tenant) => tenant.type?.toUpperCase() === type),
|
|
}))
|
|
.filter((group) => group.tenants.length > 0);
|
|
}
|
|
|
|
export function buildTenantImportPreview(
|
|
rows: TenantCSVRow[],
|
|
tenants: TenantSummary[],
|
|
): TenantImportPreviewRow[] {
|
|
return rows
|
|
.map((row) => {
|
|
const candidates = findTenantCandidates(row, tenants);
|
|
const conflicts = findTenantImportConflicts(row, tenants);
|
|
return {
|
|
row,
|
|
candidates,
|
|
conflicts,
|
|
defaultTenantId:
|
|
candidates[0] && candidates[0].score >= 0.95
|
|
? candidates[0].tenantId
|
|
: "",
|
|
defaultCreateSlug: suggestUniqueTenantSlug(
|
|
row.slug || row.name,
|
|
tenants,
|
|
),
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
const aScore = a.candidates[0]?.score ?? 0;
|
|
const bScore = b.candidates[0]?.score ?? 0;
|
|
if (bScore !== aScore) return bScore - aScore;
|
|
return a.row.rowNumber - b.row.rowNumber;
|
|
});
|
|
}
|
|
|
|
export function serializeTenantImportCSV(
|
|
previewRows: TenantImportPreviewRow[],
|
|
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
|
) {
|
|
const lines = [importHeaders];
|
|
const sortedRows = [...previewRows].sort(
|
|
(a, b) => a.row.rowNumber - b.row.rowNumber,
|
|
);
|
|
const targetTenantIds = buildTargetTenantIds(sortedRows, selectedTenantIds);
|
|
|
|
for (const preview of sortedRows) {
|
|
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
|
if (typeof resolution === "object" && resolution.mode === "skip") {
|
|
continue;
|
|
}
|
|
|
|
const selectedTenantId =
|
|
typeof resolution === "string"
|
|
? resolution
|
|
: resolution.mode === "existing"
|
|
? resolution.tenantId
|
|
: "";
|
|
const slug =
|
|
typeof resolution === "object" && resolution.mode === "create"
|
|
? resolution.slug || preview.defaultCreateSlug
|
|
: preview.row.slug;
|
|
const hasParentTenantIdOverride =
|
|
typeof resolution === "object" &&
|
|
Object.hasOwn(resolution, "parentTenantId");
|
|
const hasParentTenantSlugOverride =
|
|
typeof resolution === "object" &&
|
|
Object.hasOwn(resolution, "parentTenantSlug");
|
|
const sourceParentTenantSlug = hasParentTenantSlugOverride
|
|
? resolution.parentTenantSlug || ""
|
|
: preview.row.parentTenantSlug;
|
|
const parentTenantId =
|
|
typeof resolution === "object"
|
|
? hasParentTenantIdOverride
|
|
? resolution.parentTenantId || ""
|
|
: remapParentTenantId(
|
|
preview.row.parentTenantId,
|
|
sourceParentTenantSlug,
|
|
targetTenantIds,
|
|
)
|
|
: preview.row.parentTenantId;
|
|
const parentTenantSlug = remapParentTenantSlug(
|
|
sourceParentTenantSlug,
|
|
targetTenantIds,
|
|
);
|
|
const tenantId =
|
|
targetTenantIds.byRowNumber.get(preview.row.rowNumber) ??
|
|
selectedTenantId ??
|
|
preview.row.tenantId;
|
|
|
|
lines.push([
|
|
tenantId,
|
|
preview.row.name,
|
|
preview.row.type,
|
|
parentTenantId,
|
|
parentTenantSlug,
|
|
slug,
|
|
preview.row.memo,
|
|
preview.row.emailDomain,
|
|
preview.row.visibility,
|
|
preview.row.orgUnitType,
|
|
]);
|
|
}
|
|
return `${lines.map(formatCSVRecord).join("\n")}\n`;
|
|
}
|
|
|
|
function buildTargetTenantIds(
|
|
previewRows: TenantImportPreviewRow[],
|
|
selectedTenantIds: Record<number, string | TenantImportResolution>,
|
|
) {
|
|
const byRowNumber = new Map<number, string>();
|
|
const bySourceId = new Map<string, string>();
|
|
const bySourceSlug = new Map<string, string>();
|
|
const bySourceSlugToTargetSlug = new Map<string, string>();
|
|
|
|
for (const preview of previewRows) {
|
|
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
|
if (typeof resolution === "object" && resolution.mode === "skip") {
|
|
continue;
|
|
}
|
|
|
|
const targetTenantId =
|
|
typeof resolution === "string"
|
|
? resolution || preview.row.tenantId
|
|
: resolution.mode === "existing"
|
|
? resolution.tenantId
|
|
: resolution.tenantId || createTenantImportId();
|
|
const targetSlug =
|
|
typeof resolution === "object" && resolution.mode === "create"
|
|
? resolution.slug || preview.defaultCreateSlug
|
|
: preview.row.slug;
|
|
|
|
if (targetTenantId) {
|
|
byRowNumber.set(preview.row.rowNumber, targetTenantId);
|
|
}
|
|
if (preview.row.tenantId) {
|
|
bySourceId.set(preview.row.tenantId, targetTenantId);
|
|
}
|
|
if (preview.row.slug) {
|
|
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
|
|
bySourceSlugToTargetSlug.set(preview.row.slug.toLowerCase(), targetSlug);
|
|
}
|
|
if (targetSlug) {
|
|
bySourceSlug.set(targetSlug.toLowerCase(), targetTenantId);
|
|
bySourceSlugToTargetSlug.set(targetSlug.toLowerCase(), targetSlug);
|
|
}
|
|
}
|
|
|
|
return { byRowNumber, bySourceId, bySourceSlug, bySourceSlugToTargetSlug };
|
|
}
|
|
|
|
function remapParentTenantId(
|
|
parentTenantId: string,
|
|
parentTenantSlug: string,
|
|
targetTenantIds: {
|
|
byRowNumber: Map<number, string>;
|
|
bySourceId: Map<string, string>;
|
|
bySourceSlug: Map<string, string>;
|
|
bySourceSlugToTargetSlug: Map<string, string>;
|
|
},
|
|
) {
|
|
if (parentTenantId) {
|
|
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
|
|
}
|
|
if (parentTenantSlug) {
|
|
return (
|
|
targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? ""
|
|
);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function remapParentTenantSlug(
|
|
parentTenantSlug: string,
|
|
targetTenantIds: {
|
|
bySourceSlugToTargetSlug: Map<string, string>;
|
|
},
|
|
) {
|
|
if (!parentTenantSlug) return "";
|
|
return (
|
|
targetTenantIds.bySourceSlugToTargetSlug.get(
|
|
parentTenantSlug.toLowerCase(),
|
|
) ?? parentTenantSlug
|
|
);
|
|
}
|
|
|
|
function createTenantImportId() {
|
|
if (globalThis.crypto?.randomUUID) {
|
|
return globalThis.crypto.randomUUID();
|
|
}
|
|
return `00000000-0000-4000-8000-${Math.random()
|
|
.toString(16)
|
|
.slice(2, 14)
|
|
.padEnd(12, "0")}`;
|
|
}
|
|
|
|
function findTenantImportConflicts(
|
|
row: TenantCSVRow,
|
|
tenants: TenantSummary[],
|
|
): TenantImportConflict[] {
|
|
const conflicts: TenantImportConflict[] = [];
|
|
const matchingId = row.tenantId
|
|
? tenants.find((tenant) => tenant.id === row.tenantId)
|
|
: undefined;
|
|
const matchingSlug = row.slug
|
|
? tenants.find(
|
|
(tenant) => normalizeToken(tenant.slug) === normalizeToken(row.slug),
|
|
)
|
|
: undefined;
|
|
|
|
if (row.tenantId && !matchingId) {
|
|
conflicts.push("external_tenant_id");
|
|
}
|
|
if (matchingSlug && matchingSlug.id !== row.tenantId) {
|
|
conflicts.push("slug_exists");
|
|
}
|
|
if (
|
|
row.parentTenantId &&
|
|
!tenants.some((tenant) => tenant.id === row.parentTenantId)
|
|
) {
|
|
conflicts.push("parent_tenant_id_unresolved");
|
|
}
|
|
|
|
return conflicts;
|
|
}
|
|
|
|
function findTenantCandidates(
|
|
row: TenantCSVRow,
|
|
tenants: TenantSummary[],
|
|
): TenantImportCandidate[] {
|
|
return tenants
|
|
.map((tenant) => {
|
|
const nameScore = similarity(row.name, tenant.name);
|
|
const slugScore =
|
|
normalizeToken(row.slug) &&
|
|
normalizeToken(row.slug) === normalizeToken(tenant.slug)
|
|
? 0.98
|
|
: 0;
|
|
const exactName =
|
|
normalizeToken(row.name) === normalizeToken(tenant.name);
|
|
const score = exactName ? 1 : Math.max(slugScore, nameScore);
|
|
const reason: TenantImportCandidate["reason"] = exactName
|
|
? "exact_name"
|
|
: slugScore >= 0.98
|
|
? "exact_slug"
|
|
: "similar_name";
|
|
return {
|
|
tenantId: tenant.id,
|
|
name: tenant.name,
|
|
slug: tenant.slug,
|
|
score,
|
|
reason,
|
|
};
|
|
})
|
|
.filter((candidate) => candidate.score >= 0.45)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 3);
|
|
}
|
|
|
|
function parseCSV(text: string): string[][] {
|
|
const rows: string[][] = [];
|
|
let current = "";
|
|
let row: string[] = [];
|
|
let quoted = false;
|
|
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
const char = text[i];
|
|
const next = text[i + 1];
|
|
|
|
if (char === '"' && quoted && next === '"') {
|
|
current += '"';
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (char === '"') {
|
|
quoted = !quoted;
|
|
continue;
|
|
}
|
|
if (char === "," && !quoted) {
|
|
row.push(current);
|
|
current = "";
|
|
continue;
|
|
}
|
|
if ((char === "\n" || char === "\r") && !quoted) {
|
|
if (char === "\r" && next === "\n") i += 1;
|
|
row.push(current);
|
|
rows.push(row);
|
|
row = [];
|
|
current = "";
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
|
|
if (current !== "" || row.length > 0) {
|
|
row.push(current);
|
|
rows.push(row);
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function formatCSVRecord(record: string[]) {
|
|
return record
|
|
.map((value) => {
|
|
if (!/[",\r\n]/.test(value)) return value;
|
|
return `"${value.replaceAll('"', '""')}"`;
|
|
})
|
|
.join(",");
|
|
}
|
|
|
|
function normalizeHeader(value: string) {
|
|
return value.trim().toLowerCase().replaceAll(" ", "_");
|
|
}
|
|
|
|
function slugFromMailingList(value: string) {
|
|
if (!value) return "";
|
|
return normalizeTenantSlug(value.split("@")[0] ?? value);
|
|
}
|
|
|
|
function slugFromParentOrg(value: string, slugByName: Map<string, string>) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return "";
|
|
const match = trimmed.match(/\(([^)]+)\)/);
|
|
if (match?.[1]) {
|
|
return slugFromMailingList(match[1]);
|
|
}
|
|
return slugByName.get(trimmed) ?? normalizeTenantSlug(trimmed);
|
|
}
|
|
|
|
function normalizeTenantSlug(value: string) {
|
|
let slug = value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-");
|
|
slug = slug.replace(/^-+|-+$/g, "");
|
|
if (slug.length > 25) {
|
|
slug = slug.slice(0, 25).replace(/-+$/g, "");
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
function normalizeToken(value: string) {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s_-]+/g, "")
|
|
.replace(/[^\p{L}\p{N}]/gu, "");
|
|
}
|
|
|
|
function suggestUniqueTenantSlug(value: string, tenants: TenantSummary[]) {
|
|
const base = slugify(value) || "tenant";
|
|
const used = new Set(tenants.map((tenant) => tenant.slug.toLowerCase()));
|
|
if (!used.has(base)) {
|
|
return base;
|
|
}
|
|
|
|
let index = 2;
|
|
while (used.has(`${base}-${index}`)) {
|
|
index += 1;
|
|
}
|
|
return `${base}-${index}`;
|
|
}
|
|
|
|
function slugify(value: string) {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
function similarity(left: string, right: string) {
|
|
const a = normalizeToken(left);
|
|
const b = normalizeToken(right);
|
|
if (!a || !b) return 0;
|
|
if (a === b) return 1;
|
|
if (a.includes(b) || b.includes(a)) {
|
|
return Math.min(a.length, b.length) / Math.max(a.length, b.length);
|
|
}
|
|
|
|
const distance = levenshtein(a, b);
|
|
return 1 - distance / Math.max(a.length, b.length);
|
|
}
|
|
|
|
function levenshtein(left: string, right: string) {
|
|
const previous = Array.from({ length: right.length + 1 }, (_, i) => i);
|
|
const current = Array.from({ length: right.length + 1 }, () => 0);
|
|
|
|
for (let i = 1; i <= left.length; i += 1) {
|
|
current[0] = i;
|
|
for (let j = 1; j <= right.length; j += 1) {
|
|
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
current[j] = Math.min(
|
|
current[j - 1] + 1,
|
|
previous[j] + 1,
|
|
previous[j - 1] + cost,
|
|
);
|
|
}
|
|
previous.splice(0, previous.length, ...current);
|
|
}
|
|
|
|
return previous[right.length];
|
|
}
|