import type { TenantSummary } from "../../../lib/adminApi"; export type TenantCSVRow = { rowNumber: number; tenantId: string; name: string; type: string; parentTenantId: string; slug: string; memo: string; emailDomain: string; }; 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; }; const importHeaders = [ "tenant_id", "name", "type", "parent_tenant_id", "slug", "memo", "email_domain", ]; const headerAliases: Record = { id: "tenantId", tenantid: "tenantId", tenant_id: "tenantId", name: "name", type: "type", parentid: "parentTenantId", parent_id: "parentTenantId", parenttenantid: "parentTenantId", parent_tenant_id: "parentTenantId", slug: "slug", memo: "memo", description: "memo", "email-domain": "emailDomain", emaildomain: "emailDomain", email_domain: "emailDomain", domain: "emailDomain", domains: "emailDomain", }; export function parseTenantCSV(text: string): 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); }); return records.slice(1).flatMap((record, index) => { if (record.every((value) => value.trim() === "")) return []; const value = (key: keyof TenantCSVRow) => { const columnIndex = header.get(key); if (columnIndex === undefined) return ""; return (record[columnIndex] ?? "").trim(); }; return { rowNumber: index + 2, tenantId: value("tenantId"), name: value("name"), type: value("type"), parentTenantId: value("parentTenantId"), slug: value("slug"), memo: value("memo"), emailDomain: value("emailDomain"), }; }); } export function buildTenantImportPreview( rows: TenantCSVRow[], tenants: TenantSummary[], ): TenantImportPreviewRow[] { return rows .map((row) => { const candidates = row.tenantId ? [] : findTenantCandidates(row, tenants); return { row, candidates, defaultTenantId: candidates[0] && candidates[0].score >= 0.95 ? candidates[0].tenantId : "", }; }) .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]; for (const preview of [...previewRows].sort( (a, b) => a.row.rowNumber - b.row.rowNumber, )) { const selectedTenantId = selectedTenantIds[preview.row.rowNumber] ?? ""; lines.push([ preview.row.tenantId || selectedTenantId, preview.row.name, preview.row.type, preview.row.parentTenantId, preview.row.slug, preview.row.memo, preview.row.emailDomain, ]); } return lines.map(formatCSVRecord).join("\n") + "\n"; } 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 normalizeToken(value: string) { return value .trim() .toLowerCase() .replace(/[\s_-]+/g, "") .replace(/[^\p{L}\p{N}]/gu, ""); } 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]; }