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 = { 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(); 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, ) { 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, ) { const byRowNumber = new Map(); const bySourceId = new Map(); const bySourceSlug = new Map(); const bySourceSlugToTargetSlug = new Map(); 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; bySourceId: Map; bySourceSlug: Map; bySourceSlugToTargetSlug: Map; }, ) { if (parentTenantId) { return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId; } if (parentTenantSlug) { return ( targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? "" ); } return ""; } function remapParentTenantSlug( parentTenantSlug: string, targetTenantIds: { bySourceSlugToTargetSlug: Map; }, ) { 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) { 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]; }