forked from baron/baron-sso
조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인
This commit is contained in:
60
adminfront/src/features/tenants/utils/orgConfig.test.ts
Normal file
60
adminfront/src/features/tenants/utils/orgConfig.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
mergeTenantOrgConfig,
|
||||
readTenantOrgConfig,
|
||||
shouldAllowHanmacOrgConfig,
|
||||
} from "./orgConfig";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
slug,
|
||||
description: "",
|
||||
status: "active",
|
||||
parentId,
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("tenant org config", () => {
|
||||
it("allows org config only for hanmac-family descendants", () => {
|
||||
const family = tenant(
|
||||
"family",
|
||||
"COMPANY_GROUP",
|
||||
"한맥가족",
|
||||
"hanmac-family",
|
||||
);
|
||||
const saman = tenant("saman", "COMPANY", "삼안", "saman", "family");
|
||||
const team = tenant("team", "USER_GROUP", "기획팀", "planning", "saman");
|
||||
const outsider = tenant("outsider", "COMPANY", "외부", "outsider");
|
||||
const tenants = [family, saman, team, outsider];
|
||||
|
||||
expect(shouldAllowHanmacOrgConfig(team, tenants)).toBe(true);
|
||||
expect(shouldAllowHanmacOrgConfig(family, tenants)).toBe(false);
|
||||
expect(shouldAllowHanmacOrgConfig(outsider, tenants)).toBe(false);
|
||||
});
|
||||
|
||||
it("reads and writes tenant visibility and org unit type", () => {
|
||||
expect(
|
||||
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
|
||||
).toEqual({ orgUnitType: "팀", visibility: "private" });
|
||||
|
||||
expect(
|
||||
mergeTenantOrgConfig(
|
||||
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
|
||||
{ orgUnitType: "", visibility: "internal" },
|
||||
),
|
||||
).toEqual({ userSchema: [], visibility: "internal" });
|
||||
});
|
||||
});
|
||||
92
adminfront/src/features/tenants/utils/orgConfig.ts
Normal file
92
adminfront/src/features/tenants/utils/orgConfig.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
|
||||
export const ORG_UNIT_TYPE_OPTIONS = [
|
||||
"실",
|
||||
"팀",
|
||||
"디비전",
|
||||
"셀",
|
||||
"본부",
|
||||
"지역본부",
|
||||
"부",
|
||||
] as const;
|
||||
|
||||
export const TENANT_VISIBILITY_OPTIONS = [
|
||||
{ label: "공개", value: "public" },
|
||||
{ label: "내부", value: "internal" },
|
||||
{ label: "비공개", value: "private" },
|
||||
] as const;
|
||||
|
||||
export type TenantVisibility =
|
||||
(typeof TENANT_VISIBILITY_OPTIONS)[number]["value"];
|
||||
|
||||
export type TenantOrgConfig = {
|
||||
orgUnitType: string;
|
||||
visibility: TenantVisibility;
|
||||
};
|
||||
|
||||
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
|
||||
const TENANT_VISIBILITY_SET = new Set<string>(
|
||||
TENANT_VISIBILITY_OPTIONS.map((option) => option.value),
|
||||
);
|
||||
|
||||
export function shouldAllowHanmacOrgConfig(
|
||||
tenant: Pick<TenantSummary, "id" | "parentId" | "slug">,
|
||||
tenants: Array<Pick<TenantSummary, "id" | "parentId" | "slug">>,
|
||||
) {
|
||||
if (tenant.slug.toLowerCase() === "hanmac-family") return false;
|
||||
|
||||
const byId = new Map(tenants.map((item) => [item.id, item]));
|
||||
let parentId = tenant.parentId;
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (parentId) {
|
||||
if (visited.has(parentId)) return false;
|
||||
visited.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (!parent) return false;
|
||||
if (parent.slug.toLowerCase() === "hanmac-family") return true;
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function readTenantOrgConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): TenantOrgConfig {
|
||||
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
|
||||
const rawOrgUnitType = String(config?.orgUnitType ?? "");
|
||||
|
||||
return {
|
||||
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
|
||||
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
|
||||
? (rawVisibility as TenantVisibility)
|
||||
: "public",
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeTenantOrgConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
next: TenantOrgConfig,
|
||||
) {
|
||||
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
|
||||
const merged = { ...rest };
|
||||
merged.visibility = next.visibility;
|
||||
|
||||
if (next.orgUnitType) {
|
||||
merged.orgUnitType = next.orgUnitType;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function removeTenantOrgConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const {
|
||||
orgUnitType: _orgUnitType,
|
||||
visibility: _visibility,
|
||||
...rest
|
||||
} = config ?? {};
|
||||
return rest;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import {
|
||||
buildTenantImportParentOptionGroups,
|
||||
buildTenantImportPreview,
|
||||
inferTenantImportRootParentSlug,
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "./tenantCsvImport";
|
||||
@@ -31,9 +33,37 @@ const tenants: TenantSummary[] = [
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "tenant-3",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "Hanmac Family",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "tenant-4",
|
||||
type: "ORGANIZATION",
|
||||
name: "기획부",
|
||||
slug: "planning",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
describe("tenantCsvImport", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("parses tenant CSV rows with the supported import columns", () => {
|
||||
const rows = parseTenantCSV(
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
|
||||
@@ -87,7 +117,7 @@ describe("tenantCsvImport", () => {
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
"tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com",
|
||||
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -110,7 +140,7 @@ describe("tenantCsvImport", () => {
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
"staging-new-tenant-id,Hanmac Technology,COMPANY,,hanmac-imported,Memo,hanmac.example.com",
|
||||
"staging-new-tenant-id,Hanmac Technology,COMPANY,,,hanmac-imported,Memo,hanmac.example.com",
|
||||
);
|
||||
expect(csv).not.toContain("local-tenant-id");
|
||||
});
|
||||
@@ -138,10 +168,10 @@ describe("tenantCsvImport", () => {
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
"staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
|
||||
"staging-parent-id,Parent Tenant,COMPANY,,,parent-staging,,",
|
||||
);
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-staging,,",
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,,child-staging,,",
|
||||
);
|
||||
expect(csv).not.toContain("local-parent-id");
|
||||
expect(csv).not.toContain("local-child-id");
|
||||
@@ -171,7 +201,157 @@ describe("tenantCsvImport", () => {
|
||||
|
||||
expect(rows[1].parentTenantSlug).toBe("parent-slug");
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-slug,,",
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps parent_tenant_slug in the serialized CSV as a fallback for hierarchy import", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||
"Parent Tenant,COMPANY,,parent-slug,,",
|
||||
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
tenantId: "staging-parent-id",
|
||||
slug: "parent-slug",
|
||||
},
|
||||
3: {
|
||||
mode: "create",
|
||||
tenantId: "staging-child-id",
|
||||
slug: "child-slug",
|
||||
},
|
||||
});
|
||||
|
||||
expect(csv.split("\n")[0]).toBe(
|
||||
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain",
|
||||
);
|
||||
expect(csv).toContain(
|
||||
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses Naver Works organization CSV columns into tenant import rows", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
'"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","상위 조직"',
|
||||
'"기술개발센터","1","","","","tdc@samaneng.com",""',
|
||||
'"기획부","1","","","","planning@samaneng.com","기술개발센터(tdc@samaneng.com)"',
|
||||
'"업무팀","0","","","","t_226wn@samaneng.com","기획부(planning@samaneng.com)"',
|
||||
].join("\n"),
|
||||
{ rootParentSlug: "saman" },
|
||||
);
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{
|
||||
name: "기술개발센터",
|
||||
type: "ORGANIZATION",
|
||||
slug: "tdc",
|
||||
parentTenantSlug: "saman",
|
||||
},
|
||||
{
|
||||
name: "기획부",
|
||||
type: "ORGANIZATION",
|
||||
slug: "planning",
|
||||
parentTenantSlug: "tdc",
|
||||
},
|
||||
{
|
||||
name: "업무팀",
|
||||
type: "ORGANIZATION",
|
||||
slug: "t-226wn",
|
||||
parentTenantSlug: "planning",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("infers root parent slug from an organization CSV file prefix that matches an existing slug", () => {
|
||||
expect(inferTenantImportRootParentSlug("saman_org.csv", tenants)).toBe(
|
||||
"saman",
|
||||
);
|
||||
expect(
|
||||
inferTenantImportRootParentSlug("/tmp/hanmac-family_org.csv", tenants),
|
||||
).toBe("hanmac-family");
|
||||
expect(
|
||||
inferTenantImportRootParentSlug("saman_org_slugged.csv", tenants),
|
||||
).toBe("saman");
|
||||
expect(inferTenantImportRootParentSlug("unknown_org.csv", tenants)).toBe(
|
||||
"",
|
||||
);
|
||||
expect(inferTenantImportRootParentSlug("tenant-import.csv", tenants)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("groups existing parent candidates by company group, company, and organization", () => {
|
||||
const groups = buildTenantImportParentOptionGroups(tenants);
|
||||
|
||||
expect(groups.map((group) => group.type)).toEqual([
|
||||
"COMPANY_GROUP",
|
||||
"COMPANY",
|
||||
"ORGANIZATION",
|
||||
]);
|
||||
expect(
|
||||
groups.map((group) => group.tenants.map((tenant) => tenant.id)),
|
||||
).toEqual([["tenant-3"], ["tenant-1", "tenant-2"], ["tenant-4"]]);
|
||||
});
|
||||
|
||||
it("keeps generated ids stable and follows edited parent slugs for child rows", () => {
|
||||
const randomUUID = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("parent-generated-id")
|
||||
.mockReturnValueOnce("child-generated-id");
|
||||
vi.stubGlobal("crypto", { randomUUID });
|
||||
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
|
||||
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: { mode: "create", slug: "tech-center" },
|
||||
3: { mode: "create", slug: "structure-div" },
|
||||
});
|
||||
|
||||
expect(csv).toContain(
|
||||
"parent-generated-id,기술개발센터,ORGANIZATION,,saman,tech-center,,",
|
||||
);
|
||||
expect(csv).toContain(
|
||||
"child-generated-id,일반구조물 div,ORGANIZATION,parent-generated-id,tech-center,structure-div,,",
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes explicit parent tenant selections from the import preview", () => {
|
||||
const rows = parseTenantCSV(
|
||||
[
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain",
|
||||
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
|
||||
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
|
||||
].join("\n"),
|
||||
);
|
||||
const preview = buildTenantImportPreview(rows, tenants);
|
||||
const csv = serializeTenantImportCSV(preview, {
|
||||
2: {
|
||||
mode: "create",
|
||||
slug: "tech-center",
|
||||
parentTenantId: "tenant-2",
|
||||
parentTenantSlug: "",
|
||||
},
|
||||
3: {
|
||||
mode: "create",
|
||||
slug: "structure-div",
|
||||
parentTenantSlug: "tech-center",
|
||||
},
|
||||
});
|
||||
|
||||
expect(csv).toContain("기술개발센터,ORGANIZATION,tenant-2,,tech-center,,");
|
||||
expect(csv).toContain(",일반구조물 div,ORGANIZATION,");
|
||||
expect(csv).toContain(",tech-center,structure-div,,");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,12 @@ export type TenantCSVRow = {
|
||||
emailDomain: string;
|
||||
};
|
||||
|
||||
export type TenantCSVParseOptions = {
|
||||
rootParentSlug?: string;
|
||||
};
|
||||
|
||||
type TenantCSVSourceKey = keyof TenantCSVRow | "mailingList" | "parentOrg";
|
||||
|
||||
export type TenantImportCandidate = {
|
||||
tenantId: string;
|
||||
name: string;
|
||||
@@ -28,6 +34,16 @@ export type TenantImportPreviewRow = {
|
||||
conflicts: TenantImportConflict[];
|
||||
};
|
||||
|
||||
export type TenantImportParentOptionGroupType =
|
||||
| "COMPANY_GROUP"
|
||||
| "COMPANY"
|
||||
| "ORGANIZATION";
|
||||
|
||||
export type TenantImportParentOptionGroup = {
|
||||
type: TenantImportParentOptionGroupType;
|
||||
tenants: TenantSummary[];
|
||||
};
|
||||
|
||||
export type TenantImportConflict =
|
||||
| "external_tenant_id"
|
||||
| "slug_exists"
|
||||
@@ -37,12 +53,15 @@ export type TenantImportResolution =
|
||||
| {
|
||||
mode: "existing";
|
||||
tenantId: string;
|
||||
parentTenantId?: string;
|
||||
parentTenantSlug?: string;
|
||||
}
|
||||
| {
|
||||
mode: "create";
|
||||
tenantId?: string;
|
||||
slug?: string;
|
||||
parentTenantId?: string;
|
||||
parentTenantSlug?: string;
|
||||
}
|
||||
| {
|
||||
mode: "skip";
|
||||
@@ -53,16 +72,18 @@ const importHeaders = [
|
||||
"name",
|
||||
"type",
|
||||
"parent_tenant_id",
|
||||
"parent_tenant_slug",
|
||||
"slug",
|
||||
"memo",
|
||||
"email_domain",
|
||||
];
|
||||
|
||||
const headerAliases: Record<string, keyof TenantCSVRow> = {
|
||||
const headerAliases: Record<string, TenantCSVSourceKey> = {
|
||||
id: "tenantId",
|
||||
tenantid: "tenantId",
|
||||
tenant_id: "tenantId",
|
||||
name: "name",
|
||||
조직명: "name",
|
||||
type: "type",
|
||||
parentid: "parentTenantId",
|
||||
parent_id: "parentTenantId",
|
||||
@@ -70,9 +91,12 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
|
||||
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",
|
||||
@@ -80,39 +104,96 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
|
||||
domains: "emailDomain",
|
||||
};
|
||||
|
||||
export function parseTenantCSV(text: string): TenantCSVRow[] {
|
||||
export function parseTenantCSV(
|
||||
text: string,
|
||||
options: TenantCSVParseOptions = {},
|
||||
): TenantCSVRow[] {
|
||||
const records = parseCSV(text.replace(/^\uFEFF/, ""));
|
||||
if (records.length === 0) return [];
|
||||
|
||||
const header = new Map<keyof TenantCSVRow, number>();
|
||||
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);
|
||||
});
|
||||
|
||||
return records.slice(1).flatMap((record, 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: keyof TenantCSVRow) => {
|
||||
const value = (key: TenantCSVSourceKey) => {
|
||||
const columnIndex = header.get(key);
|
||||
if (columnIndex === undefined) return "";
|
||||
return (record[columnIndex] ?? "").trim();
|
||||
};
|
||||
|
||||
return {
|
||||
raw: record,
|
||||
rowNumber: index + 2,
|
||||
tenantId: value("tenantId"),
|
||||
name: value("name"),
|
||||
type: value("type"),
|
||||
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: value("parentTenantSlug"),
|
||||
slug: value("slug"),
|
||||
parentTenantSlug,
|
||||
slug,
|
||||
memo: value("memo"),
|
||||
emailDomain: value("emailDomain"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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[],
|
||||
@@ -169,27 +250,40 @@ export function serializeTenantImportCSV(
|
||||
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" && resolution.mode === "create"
|
||||
? (resolution.parentTenantId ??
|
||||
remapParentTenantId(
|
||||
preview.row.parentTenantId,
|
||||
preview.row.parentTenantSlug,
|
||||
targetTenantIds,
|
||||
))
|
||||
typeof resolution === "object"
|
||||
? hasParentTenantIdOverride
|
||||
? resolution.parentTenantId || ""
|
||||
: remapParentTenantId(
|
||||
preview.row.parentTenantId,
|
||||
sourceParentTenantSlug,
|
||||
targetTenantIds,
|
||||
)
|
||||
: preview.row.parentTenantId;
|
||||
const parentTenantSlug = remapParentTenantSlug(
|
||||
sourceParentTenantSlug,
|
||||
targetTenantIds,
|
||||
);
|
||||
const tenantId =
|
||||
typeof resolution === "object" && resolution.mode === "create"
|
||||
? (resolution.tenantId ??
|
||||
targetTenantIds.bySourceId.get(preview.row.tenantId) ??
|
||||
createTenantImportId())
|
||||
: selectedTenantId || preview.row.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,
|
||||
@@ -202,8 +296,10 @@ 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] ?? "";
|
||||
@@ -217,24 +313,38 @@ function buildTargetTenantIds(
|
||||
: 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 { bySourceId, bySourceSlug };
|
||||
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) {
|
||||
@@ -248,6 +358,20 @@ function remapParentTenantId(
|
||||
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();
|
||||
@@ -377,6 +501,33 @@ 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()
|
||||
|
||||
Reference in New Issue
Block a user