diff --git a/adminfront/gpdtdc_org_slugged.csv b/adminfront/gpdtdc_org_slugged.csv index 9534a9b2..e450d0fa 100644 --- a/adminfront/gpdtdc_org_slugged.csv +++ b/adminfront/gpdtdc_org_slugged.csv @@ -1,50 +1,50 @@ "조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직" -"총괄기획실","0","","","","general-planning@baroncs.co.kr","Y","N","Y","Y","","","" -"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)" -"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)" -"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)" -"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)" -"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)" -"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)" -"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)" -"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)" -"네이버웍스관리용","2","","","","nw-admin-gpd@baroncs.co.kr","N","N","N","Y","","","" -"기술개발센터","0","","","","rnd-center@baroncs.co.kr","Y","N","Y","Y","","","" -"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" +"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","","" +"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)" +"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)" +"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)" +"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)" +"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)" +"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)" +"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)" +"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)" +"네이버웍스관리용","2","","","","su2@baroncs.co.kr","N","N","N","Y","","","" +"기술개발센터","0","","","","tdc@baroncs.co.kr","Y","N","Y","Y","","","" +"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" "DfMA","0","","","","dfma@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)" "일반구조물","0","","","","structural-design@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)" "구조물계획","0","","","","structure-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)" "하부구조","0","","","","substructure@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)" "CM기획","0","","","","cm-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)" "터널","0","","","","tunnel@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)" -"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" +"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" "공정관리","0","","","","schedule-control@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)" "단가산출","0","","","","cost-estimate@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)" -"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" -"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" +"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" +"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" "천지인셀","0","","","","cheonjijin-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)" "용지도셀","0","","","","land-map-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)" "단지설계 개발","0","","","","site-design-dev@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)" -"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" +"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" "비탈면/구조물","0","","","","slope-structures@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)" "Way Draw","0","","","","way-draw@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)" "Primal 평면","0","","","","primal-plan@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)" "Watch BIM","0","","","","watch-bim@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)" -"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" -"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" -"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" +"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" +"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" +"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" "Modeler","0","","","","modeler@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)" "HmEG","0","","","","hmeg@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)" "EG-BIM Draw","0","","","","eg-bim-draw@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)" "Abut&시공통합관제","0","","","","abut-control@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)" -"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" +"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" "솔루션개발","0","","","","solution-dev@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)" "ERP","0","","","","erp@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)" "웹디자인","0","","","","web-design@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)" -"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" +"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" "bCMf","0","","","","bcmf@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)" "GSIM","0","","","","gsim@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)" "PM","0","","","","project-management@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)" -"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" -"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" -"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)" +"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" +"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" +"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)" diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx new file mode 100644 index 00000000..51197cf4 --- /dev/null +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.picker.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { TenantSummary } from "../../../lib/adminApi"; +import { ParentTenantSelector } from "./ParentTenantSelector"; + +const tenants: TenantSummary[] = [ + { + id: "company-1", + type: "COMPANY", + name: "Saman Engineering", + slug: "saman", + description: "", + status: "active", + memberCount: 0, + createdAt: "", + updatedAt: "", + }, + { + id: "group-1", + type: "COMPANY_GROUP", + name: "Hanmac Family", + slug: "hanmac-family", + description: "", + status: "active", + memberCount: 0, + createdAt: "", + updatedAt: "", + }, +]; + +describe("ParentTenantSelector picker", () => { + it("opens an org-chart picker modal and applies tenant selection messages", async () => { + const onChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src"); + expect(pickerSrc).toContain("/login"); + expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker"); + + fireEvent( + window, + new MessageEvent("message", { + data: { + type: "orgfront:picker:confirm", + payload: { + selections: [ + { + type: "tenant", + id: "company-1", + name: "Saman Engineering", + }, + ], + }, + }, + }), + ); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1")); + }); + + it("keeps the current tenant out of picker message selections", async () => { + const onChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ })); + fireEvent( + window, + new MessageEvent("message", { + data: { + type: "orgfront:picker:confirm", + payload: { + selections: [ + { + type: "tenant", + id: "company-1", + name: "Saman Engineering", + }, + ], + }, + }, + }), + ); + + await waitFor(() => expect(onChange).not.toHaveBeenCalled()); + }); + + it("selects a non-hanmac parent from the local tenant picker", async () => { + const onChange = vi.fn(); + + render( + tenant.slug !== "hanmac-family"} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "다른 테넌트 선택" })); + fireEvent.change( + screen.getByPlaceholderText("테넌트 이름 또는 슬러그 검색"), + { target: { value: "saman" } }, + ); + fireEvent.click(screen.getByRole("button", { name: /Saman Engineering/ })); + + expect(onChange).toHaveBeenCalledWith("company-1"); + }); +}); diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 0e352553..5a21b7ea 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -1,9 +1,21 @@ -import { Search } from "lucide-react"; -import { useMemo, useState } from "react"; -import { Input } from "../../../components/ui/input"; +import { Building2, X } from "lucide-react"; +import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; +import { Button } from "../../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../../../components/ui/dialog"; import { Label } from "../../../components/ui/label"; import type { TenantSummary } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { + buildAuthenticatedOrgChartTenantPickerUrl, + parseOrgChartTenantSelection, +} from "../../users/orgChartPicker"; type ParentTenantSelectorProps = { id: string; @@ -14,6 +26,11 @@ type ParentTenantSelectorProps = { noneLabel: string; helpText?: string; excludeTenantId?: string; + labelAction?: ReactNode; + contextLabel?: string; + orgChartPickerLabel?: string; + localPickerLabel?: string; + localTenantFilter?: (tenant: TenantSummary) => boolean; }; const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]); @@ -45,70 +62,187 @@ export function ParentTenantSelector({ noneLabel, helpText, excludeTenantId, + labelAction, + contextLabel, + orgChartPickerLabel, + localPickerLabel, + localTenantFilter, }: ParentTenantSelectorProps) { - const [search, setSearch] = useState(""); - const [companyOnly, setCompanyOnly] = useState(false); - const filteredTenants = useMemo( - () => filterParentTenants(tenants, search, companyOnly, excludeTenantId), - [tenants, search, companyOnly, excludeTenantId], - ); + const [pickerOpen, setPickerOpen] = useState(false); + const [localPickerOpen, setLocalPickerOpen] = useState(false); + const [localSearch, setLocalSearch] = useState(""); const selectedTenant = tenants.find((tenant) => tenant.id === value); - const optionTenants = - selectedTenant && - !filteredTenants.some((tenant) => tenant.id === selectedTenant.id) - ? [selectedTenant, ...filteredTenants] - : filteredTenants; + const localCandidates = filterParentTenants( + localTenantFilter ? tenants.filter(localTenantFilter) : tenants, + localSearch, + false, + excludeTenantId, + ); + const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( + import.meta.env.ORGFRONT_URL, + ); + + useEffect(() => { + if (!pickerOpen) return; + + const onMessage = (event: MessageEvent) => { + const selection = parseOrgChartTenantSelection(event.data); + if (!selection) return; + if (excludeTenantId && selection.id === excludeTenantId) return; + + onChange(selection.id); + setPickerOpen(false); + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [excludeTenantId, onChange, pickerOpen]); return ( - - {label} - - - - - setSearch(event.target.value)} - className="pl-9" - placeholder={t( - "ui.admin.tenants.parent.search_placeholder", - "이름 또는 slug 검색", - )} - /> - - - setCompanyOnly(event.target.checked)} - className="h-4 w-4" - /> - {t("ui.admin.tenants.parent.company_only", "회사/그룹사만 표시")} - + + + {label} + + {labelAction} - onChange(event.target.value)} - > - {noneLabel} - {optionTenants.map((tenant) => ( - - {tenant.name} ({tenant.slug}) - {tenant.type} - - ))} - + readOnly + /> + + setPickerOpen(true)} + > + + {orgChartPickerLabel ?? + selectedTenant?.name ?? + t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")} + + {localPickerLabel && ( + setLocalPickerOpen(true)} + > + + {localPickerLabel} + + )} + {selectedTenant ? ( + <> + + {selectedTenant.slug} · {selectedTenant.type} + + onChange("")} + aria-label={noneLabel} + > + + + > + ) : ( + {noneLabel} + )} + {contextLabel && ( + + {contextLabel} + + )} + {helpText && ( {helpText} )} + + + + + {t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")} + + + {t( + "msg.admin.tenants.parent.picker_description", + "org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.", + )} + + + + + + + + + + {localPickerLabel ?? + t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")} + + + {t( + "msg.admin.tenants.parent.local_picker_description", + "테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.", + )} + + + + setLocalSearch(event.target.value)} + placeholder={t( + "ui.admin.tenants.parent.local_search_placeholder", + "테넌트 이름 또는 슬러그 검색", + )} + /> + + {localCandidates.map((tenant) => ( + { + onChange(tenant.id); + setLocalPickerOpen(false); + setLocalSearch(""); + }} + > + + + {tenant.name} + + + {tenant.slug} · {tenant.type} + + + + ))} + {localCandidates.length === 0 && ( + + {t( + "msg.admin.tenants.parent.local_picker_empty", + "선택할 수 있는 테넌트가 없습니다.", + )} + + )} + + + + ); } diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index a9c53987..5460f495 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Building2, Sparkles } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "../../../components/ui/button"; import { @@ -22,6 +22,13 @@ import { type ServerDomainConflict, formatDomainConflictMessage, } from "../utils/domainTags"; +import { + ORG_UNIT_TYPE_OPTIONS, + TENANT_VISIBILITY_OPTIONS, + type TenantVisibility, + mergeTenantOrgConfig, + shouldAllowHanmacOrgConfig, +} from "../utils/orgConfig"; function TenantCreatePage() { const navigate = useNavigate(); @@ -29,6 +36,9 @@ function TenantCreatePage() { const [type, setType] = useState("COMPANY"); const [slug, setSlug] = useState(""); const [parentId, setParentId] = useState(""); + const [parentStepConfirmed, setParentStepConfirmed] = useState(false); + const [orgUnitType, setOrgUnitType] = useState(""); + const [visibility, setVisibility] = useState("public"); const [description, setDescription] = useState(""); const [status, setStatus] = useState("active"); const [domains, setDomains] = useState([]); @@ -40,6 +50,31 @@ function TenantCreatePage() { queryKey: ["tenants", { limit: 1000 }], queryFn: () => fetchTenants(1000, 0), }); + const tenants = parentQuery.data?.items ?? []; + const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId); + const canConfigureHanmacOrg = useMemo(() => { + if (!selectedParentTenant) return false; + if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") { + return true; + } + return shouldAllowHanmacOrgConfig(selectedParentTenant, tenants); + }, [selectedParentTenant, tenants]); + const canEditTenantDetails = + parentStepConfirmed || Boolean(selectedParentTenant); + const parentContextLabel = selectedParentTenant + ? canConfigureHanmacOrg + ? t("ui.admin.tenants.create.parent_context.hanmac", "한맥가족 하위 테넌트") + : t("ui.admin.tenants.create.parent_context.general", "일반 하위 테넌트") + : parentStepConfirmed + ? t("ui.admin.tenants.create.parent_context.root", "최상위 테넌트") + : t( + "ui.admin.tenants.create.parent_context.pick_required", + "상위 테넌트 선택 필요", + ); + const handleParentChange = (nextParentId: string) => { + setParentId(nextParentId); + setParentStepConfirmed(false); + }; const mutation = useMutation({ mutationFn: (overrideForceDomains?: string[]) => @@ -51,6 +86,9 @@ function TenantCreatePage() { description: description || undefined, status, domains, + config: canConfigureHanmacOrg + ? mergeTenantOrgConfig(undefined, { orgUnitType, visibility }) + : undefined, forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts, }), onSuccess: () => { @@ -115,152 +153,266 @@ function TenantCreatePage() { - - - {t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "} - * - - setName(e.target.value)} - placeholder={t( - "ui.admin.tenants.create.form.name_placeholder", - "테넌트 이름을 입력하세요", - )} - /> - - - - - {t("ui.admin.tenants.create.form.type", "테넌트 유형")} - - setType(e.target.value)} - > - - {t("domain.tenant_type.company", "COMPANY (일반 기업)")} - - - {t( - "domain.tenant_type.company_group", - "COMPANY_GROUP (그룹사/지주사)", - )} - - - {t( - "domain.tenant_type.organization", - "ORGANIZATION (정규 조직)", - )} - - - {t( - "domain.tenant_type.user_group", - "USER_GROUP (내부 부서/팀)", - )} - - - {t( - "domain.tenant_type.personal", - "PERSONAL (개인 워크스페이스)", - )} - - - - - - - - {t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")} - - setSlug(e.target.value)} - placeholder={t( - "ui.admin.tenants.create.form.slug_placeholder", - "tenant-slug", - )} - /> - - - + - {t("ui.admin.tenants.create.form.description", "설명")} - - setDescription(e.target.value)} - /> - - - - {t( - "ui.admin.tenants.create.form.domains_label", - "허용된 도메인 (콤마로 구분)", - )} - - - - {t( - "msg.admin.tenants.create.form.domains_help", - "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.", - )} - - - - - {t("ui.admin.tenants.create.form.status", "상태")} - - - setStatus("active")} - > - {t("ui.common.status.active", "활성")} - - setStatus("inactive")} - > - {t("ui.common.status.inactive", "비활성")} - + + tenant.slug.toLowerCase() !== "hanmac-family" && + !shouldAllowHanmacOrgConfig(tenant, tenants) + } + labelAction={ + !selectedParentTenant ? ( + setParentStepConfirmed(true)} + > + {t( + "ui.admin.tenants.create.form.root_tenant", + "최상위 테넌트로 생성", + )} + + ) : null + } + /> + {canConfigureHanmacOrg && ( + <> + + + {t( + "ui.admin.tenants.profile.org_unit_type", + "조직 세부타입", + )} + + setOrgUnitType(event.target.value)} + > + {t("ui.common.none", "없음")} + {ORG_UNIT_TYPE_OPTIONS.map((option) => ( + + {option} + + ))} + + + + + {t("ui.admin.tenants.profile.visibility", "공개 범위")} + + + setVisibility(event.target.value as TenantVisibility) + } + > + {TENANT_VISIBILITY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + > + )} + {canEditTenantDetails && ( + <> + + + {t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "} + * + + setName(e.target.value)} + placeholder={t( + "ui.admin.tenants.create.form.name_placeholder", + "테넌트 이름을 입력하세요", + )} + /> + + + + + {t("ui.admin.tenants.create.form.type", "테넌트 유형")} + + setType(e.target.value)} + > + + {t("domain.tenant_type.company", "COMPANY (일반 기업)")} + + + {t( + "domain.tenant_type.company_group", + "COMPANY_GROUP (그룹사/지주사)", + )} + + + {t( + "domain.tenant_type.organization", + "ORGANIZATION (정규 조직)", + )} + + + {t( + "domain.tenant_type.user_group", + "USER_GROUP (내부 부서/팀)", + )} + + + {t( + "domain.tenant_type.personal", + "PERSONAL (개인 워크스페이스)", + )} + + + + + + + {t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")} + + setSlug(e.target.value)} + placeholder={t( + "ui.admin.tenants.create.form.slug_placeholder", + "tenant-slug", + )} + /> + + + + {t("ui.admin.tenants.create.form.description", "설명")} + + setDescription(e.target.value)} + /> + + + + {t( + "ui.admin.tenants.create.form.domains_label", + "허용된 도메인 (콤마로 구분)", + )} + + + + {t( + "msg.admin.tenants.create.form.domains_help", + "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.", + )} + + + + + {t("ui.admin.tenants.create.form.status", "상태")} + + + setStatus("active")} + > + {t("ui.common.status.active", "활성")} + + setStatus("inactive")} + > + {t("ui.common.status.inactive", "비활성")} + + + + > + )} + {!canEditTenantDetails && ( + + {t( + "msg.admin.tenants.create.pick_parent_first", + "상위 테넌트를 먼저 선택하세요.", + )} + + )} {errorMsg && ( diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index 1099bc84..87901297 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -1,6 +1,8 @@ import { useQuery } from "@tanstack/react-query"; +import { Copy } from "lucide-react"; import { Link, Outlet, useLocation, useParams } from "react-router-dom"; import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; @@ -40,10 +42,39 @@ function TenantDetailPage() { - - {tenantQuery.data?.name ?? - t("ui.admin.tenants.detail.loading", "불러오는 중...")} - + + + {tenantQuery.data?.name ?? + t("ui.admin.tenants.detail.loading", "불러오는 중...")} + + {tenantQuery.data?.id && ( + + + {tenantQuery.data.id} + + { + void navigator.clipboard?.writeText(tenantQuery.data.id); + }} + aria-label="테넌트 UUID 복사" + title="테넌트 UUID 복사" + data-testid="tenant-detail-copy-uuid" + > + + + + )} + {t( "ui.admin.tenants.detail.header_subtitle", diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index f78902ab..676981bf 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -141,6 +141,14 @@ function resolveDefaultImportParentRef( tenants: TenantSummary[], ) { if (preview.row.parentTenantId) { + const parentPreview = previewRows.find( + (candidate) => + candidate.row.rowNumber !== preview.row.rowNumber && + candidate.row.tenantId === preview.row.parentTenantId, + ); + if (parentPreview) { + return previewParentRef(parentPreview.row.rowNumber); + } return tenantParentRef(preview.row.parentTenantId); } if (!preview.row.parentTenantSlug) { diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 6140ae24..785bd1f0 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -288,22 +288,82 @@ export function TenantProfilePage() { - + + + + {canEditOrgConfig && ( + <> + + + {t( + "ui.admin.tenants.profile.org_unit_type", + "조직 세부타입", + )} + + setOrgUnitType(event.target.value)} + > + {t("ui.common.none", "없음")} + {ORG_UNIT_TYPE_OPTIONS.map((option) => ( + + {option} + + ))} + + + + + {t("ui.admin.tenants.profile.visibility", "공개 범위")} + + + setTenantVisibility( + event.target.value as TenantVisibility, + ) + } + > + {TENANT_VISIBILITY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + > )} - value={parentId} - onChange={setParentId} - tenants={parentQuery.data?.items ?? []} - noneLabel={t("ui.common.none", "없음 (최상위)")} - helpText={t( - "ui.admin.tenants.profile.form.parent_help", - "하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.", - )} - excludeTenantId={tenantId} - /> + {t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")} @@ -365,45 +425,6 @@ export function TenantProfilePage() { - {canEditOrgConfig && ( - - - - {t("ui.admin.tenants.profile.org_unit_type", "조직 세부타입")} - - setOrgUnitType(event.target.value)} - > - {t("ui.common.none", "없음")} - {ORG_UNIT_TYPE_OPTIONS.map((option) => ( - - {option} - - ))} - - - - - {t("ui.admin.tenants.profile.visibility", "공개 범위")} - - - setTenantVisibility(event.target.value as TenantVisibility) - } - > - {TENANT_VISIBILITY_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - )} {errorMsg && ( {errorMsg} diff --git a/adminfront/src/features/tenants/utils/orgConfig.test.ts b/adminfront/src/features/tenants/utils/orgConfig.test.ts index b6a89c0e..35c0f457 100644 --- a/adminfront/src/features/tenants/utils/orgConfig.test.ts +++ b/adminfront/src/features/tenants/utils/orgConfig.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { TenantSummary } from "../../../lib/adminApi"; import { + ORG_UNIT_TYPE_OPTIONS, mergeTenantOrgConfig, readTenantOrgConfig, shouldAllowHanmacOrgConfig, @@ -49,6 +50,9 @@ describe("tenant org config", () => { expect( readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }), ).toEqual({ orgUnitType: "팀", visibility: "private" }); + expect( + readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }), + ).toEqual({ orgUnitType: "센터", visibility: "internal" }); expect( mergeTenantOrgConfig( @@ -57,4 +61,17 @@ describe("tenant org config", () => { ), ).toEqual({ userSchema: [], visibility: "internal" }); }); + + it("includes task-force and executive-direct org unit types", () => { + expect(ORG_UNIT_TYPE_OPTIONS).toEqual( + expect.arrayContaining(["TF", "TF팀", "임원직속"]), + ); + expect(readTenantOrgConfig({ orgUnitType: "TF" }).orgUnitType).toBe("TF"); + expect(readTenantOrgConfig({ orgUnitType: "TF팀" }).orgUnitType).toBe( + "TF팀", + ); + expect(readTenantOrgConfig({ orgUnitType: "임원직속" }).orgUnitType).toBe( + "임원직속", + ); + }); }); diff --git a/adminfront/src/features/tenants/utils/orgConfig.ts b/adminfront/src/features/tenants/utils/orgConfig.ts index f77ceec6..0aab3bb0 100644 --- a/adminfront/src/features/tenants/utils/orgConfig.ts +++ b/adminfront/src/features/tenants/utils/orgConfig.ts @@ -3,11 +3,15 @@ import type { TenantSummary } from "../../../lib/adminApi"; export const ORG_UNIT_TYPE_OPTIONS = [ "실", "팀", + "TF", + "TF팀", + "센터", "디비전", "셀", "본부", "지역본부", "부", + "임원직속", ] as const; export const TENANT_VISIBILITY_OPTIONS = [ diff --git a/adminfront/src/features/users/components/UserBulkUploadModal.tsx b/adminfront/src/features/users/components/UserBulkUploadModal.tsx index 3d4776ad..0c3c98f0 100644 --- a/adminfront/src/features/users/components/UserBulkUploadModal.tsx +++ b/adminfront/src/features/users/components/UserBulkUploadModal.tsx @@ -206,7 +206,10 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { const resolveUserImportTenants = async () => { const tenants = tenantQuery.data?.items ?? []; - const tenantSlugByKey = new Map(); + const tenantByKey = new Map< + string, + { id: string; slug: string; emailDomain: string } + >(); for (const preview of tenantPreviewRows) { const key = tenantImportKeyFromRow(preview.row); @@ -215,7 +218,11 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { if (selected !== "__create__") { const tenant = tenants.find((item) => item.id === selected); if (tenant) { - tenantSlugByKey.set(key, tenant.slug); + tenantByKey.set(key, { + id: tenant.id, + slug: tenant.slug, + emailDomain: preview.row.emailDomain, + }); } continue; } @@ -231,27 +238,33 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { domains: splitTenantImportDomains(preview.row.emailDomain), status: "active", }); - tenantSlugByKey.set(key, created.slug); + tenantByKey.set(key, { + id: created.id, + slug: created.slug, + emailDomain: preview.row.emailDomain, + }); } return previewData.map((user, index) => { const key = tenantImportKeyFromUser(user); - const tenantSlug = key ? tenantSlugByKey.get(key) : user.tenantSlug; + const resolvedTenant = key ? tenantByKey.get(key) : undefined; const emailPreview = hanmacEmailPreviews[index]; const { tenantImport: _tenantImport, ...payload } = user; return { ...payload, email: emailPreview?.finalEmail ?? payload.email, - tenantSlug, + tenantId: resolvedTenant?.id ?? payload.tenantId, + tenantSlug: resolvedTenant?.slug ?? payload.tenantSlug, + emailDomain: resolvedTenant?.emailDomain ?? payload.emailDomain, }; }); }; const downloadTemplate = () => { const headers = - "email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id"; + "email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1"; const example = - "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001"; + "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002"; const blob = new Blob([`${headers}\n${example}`], { type: "text/csv;charset=utf-8;", }); diff --git a/adminfront/src/features/users/utils/csvParser.test.ts b/adminfront/src/features/users/utils/csvParser.test.ts index 3c8eb350..817e073c 100644 --- a/adminfront/src/features/users/utils/csvParser.test.ts +++ b/adminfront/src/features/users/utils/csvParser.test.ts @@ -82,7 +82,9 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl const result = parseUserCSV(csv); expect(result[0]).toMatchObject({ + tenantId: "local-tenant-id", tenantSlug: "missing-slug", + emailDomain: "missing.example.com", tenantImport: { sourceTenantId: "local-tenant-id", slug: "missing-slug", @@ -94,4 +96,36 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl }, }); }); + + it("should parse one nullable additional appointment from numbered columns", () => { + const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1 +dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002 +nullable@test.com,Nullable User,010-1111-1111,user,primary-tenant,개발팀,책임,팀장,Backend,EMP003,,,,,,`; + + const result = parseUserCSV(csv); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + tenantSlug: "primary-tenant", + department: "개발팀", + grade: "책임", + position: "팀장", + jobTitle: "Backend", + metadata: { + employee_id: "EMP001", + }, + additionalAppointments: [ + { + tenantSlug: "second-tenant", + department: "센터", + grade: "수석", + jobTitle: "Architecture", + metadata: { + employee_id: "EMP002", + }, + }, + ], + }); + expect(result[1].additionalAppointments).toBeUndefined(); + }); }); diff --git a/adminfront/src/features/users/utils/csvParser.ts b/adminfront/src/features/users/utils/csvParser.ts index 9eb246f2..db4f2ae2 100644 --- a/adminfront/src/features/users/utils/csvParser.ts +++ b/adminfront/src/features/users/utils/csvParser.ts @@ -1,4 +1,4 @@ -import type { BulkUserItem } from "../../../lib/adminApi"; +import type { BulkUserAppointment, BulkUserItem } from "../../../lib/adminApi"; export function parseUserCSV(text: string): BulkUserItem[] { const records = parseCSVRecords(text.replace(/^\uFEFF/, "")); @@ -15,6 +15,11 @@ export function parseUserCSV(text: string): BulkUserItem[] { const item: Partial & { metadata: Record } = { metadata: {}, }; + const additionalAppointment: BulkUserAppointment & { + metadata: Record; + } = { + metadata: {}, + }; for (let index = 0; index < headers.length; index++) { const header = headers[index]; @@ -38,6 +43,7 @@ export function parseUserCSV(text: string): BulkUserItem[] { slug: value, }; } else if (header === "tenant_id") { + item.tenantId = value; item.tenantImport = { ...(item.tenantImport ?? {}), sourceTenantId: value, @@ -73,6 +79,7 @@ export function parseUserCSV(text: string): BulkUserItem[] { memo: value, }; } else if (header === "email_domain" || header === "tenant_domain") { + item.emailDomain = value; item.tenantImport = { ...(item.tenantImport ?? {}), emailDomain: value, @@ -85,6 +92,20 @@ export function parseUserCSV(text: string): BulkUserItem[] { item.position = value; } else if (header === "jobtitle") { item.jobTitle = value; + } else if (header === "employee_id") { + item.metadata.employee_id = value; + } else if (header === "tenant_slug1") { + additionalAppointment.tenantSlug = value; + } else if (header === "department1") { + additionalAppointment.department = value; + } else if (header === "grade1") { + additionalAppointment.grade = value; + } else if (header === "position1") { + additionalAppointment.position = value; + } else if (header === "jobtitle1") { + additionalAppointment.jobTitle = value; + } else if (header === "employee_id1") { + additionalAppointment.metadata.employee_id = value; } else if (header === "lastname") { item.metadata.naverworks_last_name = value; } else if (header === "firstname") { @@ -149,6 +170,11 @@ export function parseUserCSV(text: string): BulkUserItem[] { } applyNaverWorksFallbacks(item); + if (additionalAppointment.tenantSlug) { + item.additionalAppointments = [ + cleanAdditionalAppointment(additionalAppointment), + ]; + } if (item.email && item.name) { data.push(item as BulkUserItem); @@ -158,6 +184,31 @@ export function parseUserCSV(text: string): BulkUserItem[] { return data; } +function cleanAdditionalAppointment( + appointment: BulkUserAppointment & { metadata: Record }, +) { + const metadata = + Object.keys(appointment.metadata).length > 0 + ? appointment.metadata + : undefined; + return { + ...(appointment.tenantId ? { tenantId: appointment.tenantId } : {}), + ...(appointment.tenantSlug ? { tenantSlug: appointment.tenantSlug } : {}), + ...(appointment.tenantName ? { tenantName: appointment.tenantName } : {}), + ...(appointment.isPrimary !== undefined + ? { isPrimary: appointment.isPrimary } + : {}), + ...(appointment.isOwner !== undefined + ? { isOwner: appointment.isOwner } + : {}), + ...(appointment.department ? { department: appointment.department } : {}), + ...(appointment.grade ? { grade: appointment.grade } : {}), + ...(appointment.position ? { position: appointment.position } : {}), + ...(appointment.jobTitle ? { jobTitle: appointment.jobTitle } : {}), + ...(metadata ? { metadata } : {}), + }; +} + function normalizeHeader(header: string) { return header .trim() diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 9ac3ef97..321a93e7 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -546,17 +546,33 @@ export type UserAppointment = { position?: string; }; +export type BulkUserAppointment = { + tenantId?: string; + tenantSlug?: string; + tenantName?: string; + isPrimary?: boolean; + isOwner?: boolean; + department?: string; + grade?: string; + position?: string; + jobTitle?: string; + metadata?: Record; +}; + export type BulkUserItem = { email: string; loginId?: string; name: string; phone?: string; role?: string; + tenantId?: string; tenantSlug?: string; + emailDomain?: string; department?: string; grade?: string; position?: string; jobTitle?: string; + additionalAppointments?: BulkUserAppointment[]; tenantImport?: { sourceTenantId?: string; slug?: string; diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 0a150c9b..6fac2209 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -110,6 +110,9 @@ test.describe("Tenants Management", () => { await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { timeout: 20000, }); + await page + .getByRole("button", { name: "최상위 테넌트로 생성" }) + .click(); const nameInput = page.locator('input[name="name"]').first(); await nameInput.fill("New Tenant"); @@ -119,14 +122,221 @@ test.describe("Tenants Management", () => { await page.locator("textarea").first().fill("Description"); - const submitBtn = page - .locator("button") - .filter({ hasText: /생성|Create/i }) - .first(); + const submitBtn = page.getByRole("button", { name: /^생성$/ }); await submitBtn.click(); await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 }); }); + test("should ask for parent tenant before tenant details", async ({ + page, + }) => { + const tenants = [ + { + id: "family-1", + name: "한맥가족", + slug: "hanmac-family", + status: "active", + type: "COMPANY_GROUP", + memberCount: 0, + parentId: null, + }, + { + id: "company-1", + name: "삼안", + slug: "saman", + status: "active", + type: "COMPANY", + memberCount: 0, + parentId: "family-1", + }, + { + id: "outside-1", + name: "외부회사", + slug: "outside", + status: "active", + type: "COMPANY", + memberCount: 0, + parentId: null, + }, + ]; + + await page.route("**/api/v1/admin/tenants**", async (route) => { + const headers = { "Access-Control-Allow-Origin": "*" }; + return route.fulfill({ + json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, + headers, + }); + }); + + await page.goto("/tenants/new"); + await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { + timeout: 20000, + }); + await expect( + page.getByRole("button", { name: "한맥가족에서 선택" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "다른 테넌트 선택" }), + ).toBeVisible(); + const parentLabelTop = await page + .getByText(/상위 테넌트/) + .first() + .evaluate((element) => element.getBoundingClientRect().top); + const rootButtonTop = await page + .getByRole("button", { name: "최상위 테넌트로 생성" }) + .evaluate((element) => element.getBoundingClientRect().top); + expect(Math.abs(parentLabelTop - rootButtonTop)).toBeLessThan(10); + await expect(page.locator('input[name="name"]')).toHaveCount(0); + + await page.getByRole("button", { name: "다른 테넌트 선택" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.getByPlaceholder("테넌트 이름 또는 슬러그 검색").fill("outside"); + await page.getByRole("button", { name: /외부회사/ }).click(); + + await expect( + page + .getByTestId("tenant-parent-picker-slot") + .getByText("outside · COMPANY"), + ).toBeVisible(); + await expect(page.getByText("일반 하위 테넌트")).toBeVisible(); + await expect(page.locator('input[name="name"]')).toBeVisible(); + await expect(page.getByLabel("조직 세부타입")).toHaveCount(0); + await expect(page.getByLabel("공개 범위")).toHaveCount(0); + + await page + .getByTestId("tenant-parent-picker-slot") + .getByRole("button", { name: "한맥가족에서 선택" }) + .click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.evaluate(() => { + window.postMessage( + { + type: "orgfront:picker:confirm", + payload: { + selections: [ + { type: "tenant", id: "family-1", name: "한맥가족" }, + ], + }, + }, + window.location.origin, + ); + }); + + await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible(); + await expect(page.getByText("한맥가족 하위 테넌트")).toBeVisible(); + await expect(page.locator('input[name="name"]')).toBeVisible(); + await expect(page.getByLabel("조직 세부타입")).toBeVisible(); + await expect(page.getByLabel("공개 범위")).toBeVisible(); + }); + + test("should create a hanmac-family child tenant with org config", async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + let createBody = ""; + const tenants = [ + { + id: "family-1", + name: "한맥가족", + slug: "hanmac-family", + status: "active", + type: "COMPANY_GROUP", + memberCount: 0, + parentId: null, + }, + { + id: "company-1", + name: "삼안", + slug: "saman", + status: "active", + type: "COMPANY", + memberCount: 0, + parentId: "family-1", + }, + ]; + + await page.route("**/api/v1/admin/tenants**", async (route) => { + const method = route.request().method(); + const headers = { "Access-Control-Allow-Origin": "*" }; + if (method === "GET") { + return route.fulfill({ + json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, + headers, + }); + } + if (method === "POST") { + createBody = route.request().postData() ?? ""; + return route.fulfill({ + json: { id: "created-tenant-id", name: "신규 센터" }, + headers, + }); + } + return route.fulfill({ json: {}, headers }); + }); + + await page.goto("/tenants/new"); + await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { + timeout: 20000, + }); + + await page.getByRole("button", { name: "한맥가족에서 선택" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.evaluate(() => { + window.postMessage( + { + type: "orgfront:picker:confirm", + payload: { + selections: [ + { type: "tenant", id: "family-1", name: "한맥가족" }, + ], + }, + }, + window.location.origin, + ); + }); + + await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible(); + await expect(page.getByLabel("조직 세부타입")).toBeVisible(); + await expect(page.getByLabel("공개 범위")).toBeVisible(); + + const layout = page.getByTestId("tenant-parent-org-config-layout"); + const parentWidth = await page + .getByTestId("tenant-parent-picker-slot") + .evaluate((element) => element.getBoundingClientRect().width); + const orgUnitWidth = await page + .getByTestId("tenant-org-unit-type-slot") + .evaluate((element) => element.getBoundingClientRect().width); + const visibilityWidth = await page + .getByTestId("tenant-visibility-slot") + .evaluate((element) => element.getBoundingClientRect().width); + const columns = await layout.evaluate((element) => + window.getComputedStyle(element).gridTemplateColumns, + ); + expect(columns.split(" ").length).toBe(4); + expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7); + expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3); + expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8); + + await page.locator('input[name="name"]').first().fill("신규 센터"); + await page.locator('input[name="slug"]').first().fill("new-center"); + await page.getByLabel("조직 세부타입").selectOption("센터"); + await page.getByLabel("공개 범위").selectOption("internal"); + await page + .locator("button") + .filter({ hasText: /생성|Create/i }) + .first() + .click(); + + await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 }); + expect(JSON.parse(createBody)).toMatchObject({ + parentId: "family-1", + config: { + orgUnitType: "센터", + visibility: "internal", + }, + }); + }); + test("should export and import tenant CSV without organization/user combined import", async ({ page, browserName, @@ -320,11 +530,11 @@ test.describe("Tenants Management", () => { expect(importBody).not.toContain("local-parent-id"); expect(importBody).not.toContain("local-child-id"); const parentMatch = importBody.match( - /([0-9a-f-]{36}),Parent Tenant,COMPANY,,parent-created/, + /([0-9a-f-]{36}),Parent Tenant,COMPANY,,,parent-created/, ); expect(parentMatch?.[1]).toBeTruthy(); expect(importBody).toContain( - `,Child Tenant,USER_GROUP,${parentMatch?.[1]},child-created`, + `,Child Tenant,USER_GROUP,${parentMatch?.[1]},parent-created,child-created`, ); }); @@ -333,11 +543,11 @@ test.describe("Tenants Management", () => { await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { timeout: 20000, }); + await page + .getByRole("button", { name: "최상위 테넌트로 생성" }) + .click(); - const submitBtn = page - .locator("button") - .filter({ hasText: /생성|Create/i }) - .first(); + const submitBtn = page.getByRole("button", { name: /^생성$/ }); await expect(submitBtn).toBeDisabled(); await page.locator('input[name="name"]').first().fill("Valid Name"); @@ -408,4 +618,120 @@ test.describe("Tenants Management", () => { .first(), ).toBeVisible(); }); + + test("should show tenant UUID at the top of tenant detail profile", async ({ + page, + }) => { + const tenantUuid = "11111111-2222-4333-8444-555555555555"; + const tenant = { + id: tenantUuid, + name: "Tenant With UUID", + slug: "tenant-with-uuid", + status: "active", + type: "COMPANY", + memberCount: 0, + parentId: null, + config: {}, + domains: [], + updatedAt: new Date().toISOString(), + }; + + await page.route("**/api/v1/admin/tenants**", async (route) => { + const url = route.request().url(); + const headers = { "Access-Control-Allow-Origin": "*" }; + if (url.includes(`/admin/tenants/${tenantUuid}`)) { + return route.fulfill({ json: tenant, headers }); + } + return route.fulfill({ + json: { items: [tenant], total: 1, limit: 1000, offset: 0 }, + headers, + }); + }); + + await page.goto(`/tenants/${tenantUuid}`); + + const titleRow = page.getByTestId("tenant-detail-title-row"); + await expect(titleRow).toBeVisible({ timeout: 20000 }); + await expect(titleRow).toContainText("Tenant With UUID"); + await expect(titleRow).toContainText(tenantUuid); + await expect(titleRow).not.toContainText("Tenant UUID"); + await expect(page.getByTestId("tenant-detail-copy-uuid")).toBeVisible(); + }); + + test("should place hanmac org config beside parent tenant picker", async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + const tenants = [ + { + id: "family-1", + name: "한맥가족", + slug: "hanmac-family", + status: "active", + type: "COMPANY_GROUP", + memberCount: 0, + parentId: null, + config: {}, + }, + { + id: "company-1", + name: "삼안", + slug: "saman", + status: "active", + type: "COMPANY", + memberCount: 0, + parentId: "family-1", + config: {}, + }, + { + id: "team-1", + name: "기획팀", + slug: "planning", + status: "active", + type: "USER_GROUP", + memberCount: 0, + parentId: "company-1", + config: { orgUnitType: "팀", visibility: "internal" }, + }, + ]; + + await page.route("**/api/v1/admin/tenants**", async (route) => { + const url = route.request().url(); + const headers = { "Access-Control-Allow-Origin": "*" }; + if (url.includes("/admin/tenants/team-1")) { + return route.fulfill({ json: tenants[2], headers }); + } + return route.fulfill({ + json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, + headers, + }); + }); + + await page.goto("/tenants/team-1"); + + const layout = page.getByTestId("tenant-parent-org-config-layout"); + await expect(layout).toBeVisible({ timeout: 20000 }); + await expect(layout).toContainText("상위 테넌트"); + await expect(layout).toContainText("조직 세부타입"); + await expect(layout).toContainText("공개 범위"); + + const columns = await layout.evaluate((element) => + window.getComputedStyle(element).gridTemplateColumns, + ); + expect(columns.split(" ").length).toBe(4); + + const parentWidth = await page + .getByTestId("tenant-parent-picker-slot") + .evaluate((element) => element.getBoundingClientRect().width); + const orgUnitWidth = await page + .getByTestId("tenant-org-unit-type-slot") + .evaluate((element) => element.getBoundingClientRect().width); + const visibilityWidth = await page + .getByTestId("tenant-visibility-slot") + .evaluate((element) => element.getBoundingClientRect().width); + + expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7); + expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3); + expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8); + }); }); diff --git a/adminfront/tests/users_bulk.spec.ts b/adminfront/tests/users_bulk.spec.ts index 0f45ea05..10346d57 100644 --- a/adminfront/tests/users_bulk.spec.ts +++ b/adminfront/tests/users_bulk.spec.ts @@ -119,7 +119,7 @@ test.describe("Users Bulk Upload", () => { const requests: string[] = []; let bulkPayload = ""; - await page.route("**/api/v1/admin/tenants", async (route) => { + await page.route("**/api/v1/admin/tenants**", async (route) => { const method = route.request().method(); requests.push(`${method} ${route.request().url()}`); @@ -184,6 +184,124 @@ test.describe("Users Bulk Upload", () => { await expect(page.getByText("new@test.com")).toBeVisible(); expect(requests.some((request) => request.startsWith("POST "))).toBe(true); + expect(bulkPayload).toContain('"tenantId":"staging-missing-tenant-id"'); expect(bulkPayload).toContain('"tenantSlug":"missing-slug"'); + expect(bulkPayload).toContain('"emailDomain":"missing.example.com"'); + }); + + test("should include one nullable additional appointment from numbered CSV columns", async ({ + page, + }) => { + let bulkPayload = ""; + + await page.unroute("**/api/v1/**"); + await page.route("**/api/v1/user/me", async (route) => { + return route.fulfill({ + json: { + id: "admin-user", + name: "Admin", + role: "super_admin", + manageableTenants: [], + }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }); + await page.route(/\/api\/v1\/admin\/users(?:\?|$)/, async (route) => { + return route.fulfill({ + json: { items: [], total: 0, limit: 50, offset: 0 }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }); + await page.route(/\/api\/v1\/admin\/tenants(?:\?|$)/, async (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + json: { + items: [ + { + id: "tenant-primary-id", + name: "Primary Tenant", + slug: "primary-tenant", + status: "active", + type: "COMPANY", + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + { + id: "tenant-second-id", + name: "Second Tenant", + slug: "second-tenant", + status: "active", + type: "COMPANY", + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + ], + total: 2, + limit: 1000, + offset: 0, + }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + } + return route.fulfill({ + status: 201, + json: { + id: "tenant-created-id", + name: "Primary Tenant", + slug: "primary-tenant", + status: "active", + type: "COMPANY", + memberCount: 0, + updatedAt: new Date().toISOString(), + }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }); + + await page.route("**/api/v1/admin/users/bulk", async (route) => { + bulkPayload = route.request().postData() ?? ""; + return route.fulfill({ + json: { + results: [{ email: "dual@test.com", success: true, userId: "u-1" }], + }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }); + + await page.goto("/users"); + await expect(page.getByTestId("page-title")).toContainText( + /사용자|Users/i, + { timeout: 20000 }, + ); + + await page.getByTestId("bulk-import-btn").click(); + await page.locator('input[type="file"]').setInputFiles({ + name: "users.csv", + mimeType: "text/csv", + buffer: Buffer.from( + [ + "email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1", + "dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002", + ].join("\n"), + ), + }); + + await page.getByTestId("bulk-start-btn").click(); + await expect(page.getByText("dual@test.com")).toBeVisible(); + + const payload = JSON.parse(bulkPayload); + expect(payload.users[0].tenantSlug).toBe("primary-tenant"); + expect(payload.users[0].metadata.employee_id).toBe("EMP001"); + expect(payload.users[0].additionalAppointments).toEqual([ + { + tenantSlug: "second-tenant", + department: "센터", + grade: "수석", + jobTitle: "Architecture", + metadata: { + employee_id: "EMP002", + }, + }, + ]); }); }); diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index d19d92e9..4b07a58c 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -628,6 +628,7 @@ func normalizeTenantDomainInputs(values []string) []string { func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) { normalized := make(domain.JSONMap, len(config)) + orgUnitTypeError := "orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속" for key, value := range config { if key == "userSchema" { fields, err := normalizeTenantUserSchema(value) @@ -656,14 +657,14 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) { if key == "orgUnitType" { orgUnitType, ok := value.(string) if !ok { - return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부") + return nil, errors.New(orgUnitTypeError) } orgUnitType = strings.TrimSpace(orgUnitType) if orgUnitType == "" { continue } if !isAllowedOrgUnitType(orgUnitType) { - return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부") + return nil, errors.New(orgUnitTypeError) } normalized[key] = orgUnitType continue @@ -675,7 +676,7 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) { func isAllowedOrgUnitType(value string) bool { switch value { - case "실", "팀", "디비전", "셀", "본부", "지역본부", "부": + case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속": return true default: return false diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 8d3cbf83..289a5438 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -730,12 +730,25 @@ func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) { func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) { config, err := normalizeTenantConfig(map[string]any{ "visibility": "internal", - "orgUnitType": "팀", + "orgUnitType": "센터", }) assert.NoError(t, err) assert.Equal(t, "internal", config["visibility"]) - assert.Equal(t, "팀", config["orgUnitType"]) + assert.Equal(t, "센터", config["orgUnitType"]) +} + +func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) { + for _, orgUnitType := range []string{"TF", "TF팀", "임원직속"} { + t.Run(orgUnitType, func(t *testing.T) { + config, err := normalizeTenantConfig(map[string]any{ + "orgUnitType": orgUnitType, + }) + + assert.NoError(t, err) + assert.Equal(t, orgUnitType, config["orgUnitType"]) + }) + } } func TestNormalizeTenantConfigRejectsInvalidTenantVisibility(t *testing.T) { diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 45c54754..ff9440f8 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -118,6 +118,47 @@ func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, return "" } +func bulkUserEmailDomainCandidates(emailDomain string, email string) []string { + values := make([]string, 0, 2) + seen := map[string]bool{} + add := func(value string) { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" || seen[normalized] { + return + } + seen[normalized] = true + values = append(values, normalized) + } + for _, value := range strings.FieldsFunc(emailDomain, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' + }) { + add(value) + } + if _, domainPart, err := domain.SplitEmailDomain(email); err == nil { + add(domainPart) + } + return values +} + +func bulkUserAssignmentContainsTenant(appointments []any, primaryTenantID string, tenantID string) bool { + if strings.TrimSpace(tenantID) == "" { + return true + } + if primaryTenantID != "" && primaryTenantID == tenantID { + return true + } + for _, item := range appointments { + appointment, ok := item.(map[string]any) + if !ok { + continue + } + if normalizeMetadataString(appointment["tenantId"]) == tenantID { + return true + } + } + return false +} + func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) { for _, key := range keys { value, ok := metadata[key] @@ -664,17 +705,20 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { } type bulkUserItem struct { - Email string `json:"email"` - LoginID string `json:"loginId"` - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role"` - TenantSlug string `json:"tenantSlug"` - Department string `json:"department"` - Grade string `json:"grade"` - Position string `json:"position"` - JobTitle string `json:"jobTitle"` - Metadata map[string]any `json:"metadata"` + Email string `json:"email"` + LoginID string `json:"loginId"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + TenantID string `json:"tenantId"` + TenantSlug string `json:"tenantSlug"` + EmailDomain string `json:"emailDomain"` + Department string `json:"department"` + Grade string `json:"grade"` + Position string `json:"position"` + JobTitle string `json:"jobTitle"` + AdditionalAppointments []map[string]any `json:"additionalAppointments"` + Metadata map[string]any `json:"metadata"` } type bulkUserResult struct { @@ -720,15 +764,103 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { // Pre-fetch tenant data to avoid redundant DB calls type tenantCacheItem struct { ID string + Slug string + Name string Schema []interface{} Groups []domain.UserGroup LoginIDField string } tenantCache := make(map[string]tenantCacheItem) + tenantCacheByID := make(map[string]tenantCacheItem) + tenantCacheByDomain := make(map[string]tenantCacheItem) + + buildTenantCacheItem := func(tenant *domain.Tenant) tenantCacheItem { + tItem := tenantCacheItem{ + ID: tenant.ID, + Slug: tenant.Slug, + Name: tenant.Name, + } + if s, ok := tenant.Config["userSchema"].([]interface{}); ok { + tItem.Schema = s + } + if lf, ok := tenant.Config["loginIdField"].(string); ok { + tItem.LoginIDField = lf + } + if h.UserGroupRepo != nil { + if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil { + tItem.Groups = groups + } + } + return tItem + } + + cacheTenantItem := func(tItem tenantCacheItem) tenantCacheItem { + if tItem.Slug != "" { + tenantCache[strings.ToLower(strings.TrimSpace(tItem.Slug))] = tItem + } + if tItem.ID != "" { + tenantCacheByID[tItem.ID] = tItem + } + return tItem + } + + resolveTenantBySlug := func(slug string) (tenantCacheItem, error) { + normalizedSlug := strings.ToLower(strings.TrimSpace(slug)) + if normalizedSlug == "" { + return tenantCacheItem{}, errors.New("tenantSlug is required") + } + if tItem, exists := tenantCache[normalizedSlug]; exists { + return tItem, nil + } + if h.TenantService == nil { + return tenantCacheItem{}, errors.New("tenant service unavailable") + } + tenant, err := h.TenantService.GetTenantBySlug(c.Context(), normalizedSlug) + if err != nil || tenant == nil { + return tenantCacheItem{}, errors.New("invalid tenantSlug: tenant not found") + } + return cacheTenantItem(buildTenantCacheItem(tenant)), nil + } + + resolveTenantByID := func(tenantID string) (tenantCacheItem, error) { + normalizedID := strings.TrimSpace(tenantID) + if normalizedID == "" { + return tenantCacheItem{}, errors.New("tenantId is required") + } + if tItem, exists := tenantCacheByID[normalizedID]; exists { + return tItem, nil + } + if h.TenantService == nil { + return tenantCacheItem{}, errors.New("tenant service unavailable") + } + tenant, err := h.TenantService.GetTenant(c.Context(), normalizedID) + if err != nil || tenant == nil { + return tenantCacheItem{}, errors.New("invalid tenantId: tenant not found") + } + return cacheTenantItem(buildTenantCacheItem(tenant)), nil + } + + resolveTenantByDomain := func(domainName string) (tenantCacheItem, bool) { + normalizedDomain := strings.ToLower(strings.TrimSpace(domainName)) + if normalizedDomain == "" || h.TenantService == nil { + return tenantCacheItem{}, false + } + if tItem, exists := tenantCacheByDomain[normalizedDomain]; exists { + return tItem, true + } + tenant, err := h.TenantService.GetTenantByDomain(c.Context(), normalizedDomain) + if err != nil || tenant == nil { + return tenantCacheItem{}, false + } + tItem := cacheTenantItem(buildTenantCacheItem(tenant)) + tenantCacheByDomain[normalizedDomain] = tItem + return tItem, true + } for _, item := range req.Users { email := strings.TrimSpace(item.Email) name := strings.TrimSpace(item.Name) + tenantID := strings.TrimSpace(item.TenantID) tenantSlug := strings.TrimSpace(item.TenantSlug) dept := strings.TrimSpace(item.Department) @@ -737,9 +869,38 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { continue } - if tenantSlug == "" { - results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantSlug is required"}) - continue + var tItem tenantCacheItem + var err error + if tenantID != "" { + tItem, err = resolveTenantByID(tenantID) + if err != nil { + results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()}) + continue + } + if tenantSlug != "" && !strings.EqualFold(tenantSlug, tItem.Slug) { + results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantId and tenantSlug do not match"}) + continue + } + tenantSlug = tItem.Slug + } else if tenantSlug != "" { + tItem, err = resolveTenantBySlug(tenantSlug) + if err != nil { + results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()}) + continue + } + tenantSlug = tItem.Slug + } else { + for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) { + if domainTenant, ok := resolveTenantByDomain(domainName); ok { + tItem = domainTenant + tenantSlug = domainTenant.Slug + break + } + } + if tenantSlug == "" { + results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant assignment is required"}) + continue + } } // Role-based access check @@ -750,33 +911,47 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { } } - // Verify Tenant Existence and Resolve ID (with Cache) - var tItem tenantCacheItem - var exists bool - if tItem, exists = tenantCache[tenantSlug]; !exists { - if h.TenantService != nil { - tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug) - if err != nil || tenant == nil { - results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid tenantSlug: tenant not found"}) + resolvedAppointments := make([]any, 0, len(item.AdditionalAppointments)+2) + if len(item.AdditionalAppointments) > 0 { + appointmentFailed := false + for _, rawAppointment := range item.AdditionalAppointments { + appointmentTenantSlug := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantSlug"])) + if appointmentTenantSlug == "" { continue } - tItem.ID = tenant.ID - if s, ok := tenant.Config["userSchema"].([]interface{}); ok { - tItem.Schema = s + if requester != nil && requester.Role == domain.RoleTenantAdmin && appointmentTenantSlug != requester.CompanyCode { + results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"}) + appointmentFailed = true + break } - if lf, ok := tenant.Config["loginIdField"].(string); ok { - tItem.LoginIDField = lf - } - // [Fix] Cache user groups for this tenant to match department - if h.UserGroupRepo != nil { - if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil { - tItem.Groups = groups + + appointmentTenant, exists := tenantCache[strings.ToLower(appointmentTenantSlug)] + if !exists { + appointmentTenant, err = resolveTenantBySlug(appointmentTenantSlug) + if err != nil { + results = append(results, bulkUserResult{Email: email, Success: false, Message: strings.Replace(err.Error(), "tenantSlug", "additional tenantSlug", 1)}) + appointmentFailed = true + break } } - tenantCache[tenantSlug] = tItem - } else { - results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"}) + appointment := make(map[string]any, len(rawAppointment)+3) + for key, value := range rawAppointment { + if key == "tenantSlug" || key == "tenantId" || key == "tenantName" { + continue + } + appointment[key] = value + } + appointment["tenantId"] = appointmentTenant.ID + appointment["tenantSlug"] = appointmentTenant.Slug + if name := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantName"])); name != "" { + appointment["tenantName"] = name + } else { + appointment["tenantName"] = appointmentTenant.Name + } + resolvedAppointments = append(resolvedAppointments, appointment) + } + if appointmentFailed { continue } } @@ -836,6 +1011,26 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { } } + for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, userEmail) { + domainTenant, ok := resolveTenantByDomain(domainName) + if !ok || bulkUserAssignmentContainsTenant(resolvedAppointments, tItem.ID, domainTenant.ID) { + continue + } + resolvedAppointments = append(resolvedAppointments, map[string]any{ + "tenantId": domainTenant.ID, + "tenantSlug": domainTenant.Slug, + "tenantName": domainTenant.Name, + "assignmentSource": "email_domain", + "sourceDomain": strings.ToLower(strings.TrimSpace(domainName)), + }) + } + if len(resolvedAppointments) > 0 { + if item.Metadata == nil { + item.Metadata = map[string]any{} + } + item.Metadata["additionalAppointments"] = resolvedAppointments + } + password, _ := utils.GeneratePasswordWithPolicy(policy) role := item.Role if role == "" { diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 0fab108c..6c136b33 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -139,6 +139,19 @@ func (m *MockTenantServiceForUser) GetTenant(ctx context.Context, id string) (*d return args.Get(0).(*domain.Tenant), args.Error(1) } +func (m *MockTenantServiceForUser) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { + for _, call := range m.ExpectedCalls { + if call.Method == "GetTenantByDomain" { + args := m.Called(ctx, emailDomain) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) + } + } + return nil, nil +} + func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { args := m.Called(ctx, userID) if args.Get(0) == nil { @@ -432,6 +445,217 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) { }) } +func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + } + app.Post("/users/bulk", h.BulkCreateUsers) + + mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{ + ID: "t-primary", + Slug: "test-tenant", + Name: "Primary Tenant", + Config: domain.JSONMap{}, + }, nil).Once() + mockTenant.On("GetTenant", mock.Anything, "t-primary").Return(&domain.Tenant{ + ID: "t-primary", + Slug: "test-tenant", + Name: "Primary Tenant", + Config: domain.JSONMap{}, + }, nil) + mockTenant.On("GetTenantBySlug", mock.Anything, "second-tenant").Return(&domain.Tenant{ + ID: "t-second", + Slug: "second-tenant", + Name: "Second Tenant", + Config: domain.JSONMap{}, + }, nil).Once() + mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) + mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool { + appointments, ok := user.Attributes["additionalAppointments"].([]any) + if !ok || len(appointments) != 1 { + return false + } + appointment, ok := appointments[0].(map[string]any) + if !ok { + return false + } + metadata, _ := appointment["metadata"].(map[string]any) + return appointment["tenantId"] == "t-second" && + appointment["tenantSlug"] == "second-tenant" && + appointment["tenantName"] == "Second Tenant" && + appointment["department"] == "센터" && + appointment["grade"] == "수석" && + appointment["jobTitle"] == "Architecture" && + metadata["employee_id"] == "EMP002" + }), mock.Anything).Return("u-appointment", nil).Once() + + payload := map[string]interface{}{ + "users": []map[string]interface{}{ + { + "email": "dual@test.com", + "name": "Dual User", + "tenantSlug": "test-tenant", + "metadata": map[string]interface{}{"employee_id": "EMP001"}, + "additionalAppointments": []map[string]interface{}{ + { + "tenantSlug": "second-tenant", + "department": "센터", + "grade": "수석", + "jobTitle": "Architecture", + "metadata": map[string]interface{}{"employee_id": "EMP002"}, + }, + }, + }, + }, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + + assert.Equal(t, 200, resp.StatusCode) + mockTenant.AssertExpectations(t) + mockOry.AssertExpectations(t) +} + +func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + } + app.Post("/users/bulk", h.BulkCreateUsers) + + mockTenant.On("GetTenantBySlug", mock.Anything, "gpdtdc").Return(&domain.Tenant{ + ID: "t-gpdtdc", + Slug: "gpdtdc", + Name: "GPDTDC", + Config: domain.JSONMap{}, + }, nil).Once() + mockTenant.On("GetTenant", mock.Anything, "t-gpdtdc").Return(&domain.Tenant{ + ID: "t-gpdtdc", + Slug: "gpdtdc", + Name: "GPDTDC", + Config: domain.JSONMap{}, + }, nil) + mockTenant.On("GetTenantByDomain", mock.Anything, "samaneng.com").Return(&domain.Tenant{ + ID: "t-saman", + Slug: "saman", + Name: "삼안", + Status: domain.TenantStatusActive, + Config: domain.JSONMap{}, + }, nil).Once() + mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) + mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool { + if user.Attributes["tenant_id"] != "t-gpdtdc" || user.Attributes["companyCode"] != "gpdtdc" { + return false + } + appointments, ok := user.Attributes["additionalAppointments"].([]any) + if !ok || len(appointments) != 1 { + return false + } + appointment, ok := appointments[0].(map[string]any) + if !ok { + return false + } + return appointment["tenantId"] == "t-saman" && + appointment["tenantSlug"] == "saman" && + appointment["tenantName"] == "삼안" && + appointment["assignmentSource"] == "email_domain" && + appointment["sourceDomain"] == "samaneng.com" + }), mock.Anything).Return("u-domain-assigned", nil).Once() + + payload := map[string]interface{}{ + "users": []map[string]interface{}{ + { + "email": "user@samaneng.com", + "name": "Domain User", + "tenantSlug": "gpdtdc", + }, + }, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + results := result["results"].([]interface{}) + assert.True(t, results[0].(map[string]interface{})["success"].(bool)) + mockTenant.AssertExpectations(t) + mockOry.AssertExpectations(t) +} + +func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitTenantMissing(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + } + app.Post("/users/bulk", h.BulkCreateUsers) + + mockTenant.On("GetTenantByDomain", mock.Anything, "samaneng.com").Return(&domain.Tenant{ + ID: "t-saman", + Slug: "saman", + Name: "삼안", + Status: domain.TenantStatusActive, + Config: domain.JSONMap{}, + }, nil).Once() + mockTenant.On("GetTenant", mock.Anything, "t-saman").Return(&domain.Tenant{ + ID: "t-saman", + Slug: "saman", + Name: "삼안", + Status: domain.TenantStatusActive, + Config: domain.JSONMap{}, + }, nil) + mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) + mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool { + return user.Attributes["tenant_id"] == "t-saman" && + user.Attributes["companyCode"] == "saman" && + user.Attributes["additionalAppointments"] == nil + }), mock.Anything).Return("u-domain-primary", nil).Once() + + payload := map[string]interface{}{ + "users": []map[string]interface{}{ + { + "email": "user@samaneng.com", + "name": "Domain Primary User", + }, + }, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + results := result["results"].([]interface{}) + assert.True(t, results[0].(map[string]interface{})["success"].(bool)) + mockTenant.AssertExpectations(t) + mockOry.AssertExpectations(t) +} + func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go index 20e4248b..e6d5df29 100644 --- a/backend/internal/service/worksmobile_client.go +++ b/backend/internal/service/worksmobile_client.go @@ -28,6 +28,7 @@ const ( type WorksmobileDirectoryClient interface { CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error + UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error CreateUser(ctx context.Context, payload WorksmobileUserPayload) error UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error DeleteUser(ctx context.Context, userID string) error @@ -36,14 +37,15 @@ type WorksmobileDirectoryClient interface { } type WorksmobileHTTPClient struct { - BaseURL string - DirectoryToken string - SCIMToken string - HTTPClient *http.Client - OAuthConfig WorksmobileOAuthConfig - DomainIDs []int64 - tokenCache worksmobileAccessTokenCache - now func() time.Time + BaseURL string + DirectoryToken string + SCIMToken string + HTTPClient *http.Client + OAuthConfig WorksmobileOAuthConfig + DomainIDs []int64 + OrgUnitWriteDelay time.Duration + tokenCache worksmobileAccessTokenCache + now func() time.Time } type WorksmobileOAuthConfig struct { @@ -186,6 +188,103 @@ func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload Works return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload) } +func (c *WorksmobileHTTPClient) UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error { + err := c.CreateOrgUnit(ctx, payload) + if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict { + return c.BackfillOrgUnitExternalKeyByLocalPart(ctx, payload, matchLocalPart) + } + return err +} + +func (c *WorksmobileHTTPClient) BackfillOrgUnitExternalKeyByLocalPart(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error { + localPart := worksmobileMailLocalPart(matchLocalPart) + groups, err := c.ListGroups(ctx) + if err != nil { + return err + } + for _, group := range groups { + if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID { + continue + } + if group.ExternalID == payload.OrgUnitExternalKey { + if strings.TrimSpace(group.ID) == "" { + return nil + } + if delay := c.orgUnitWriteDelay(); delay > 0 { + time.Sleep(delay) + } + return c.PatchOrgUnit(ctx, group.ID, NewWorksmobileOrgUnitPatchPayload(payload)) + } + } + if localPart == "" { + return fmt.Errorf("worksmobile orgunit local-part match key is required") + } + matches := make([]WorksmobileRemoteGroup, 0, 1) + for _, group := range groups { + if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID { + continue + } + if group.MailLocalPart == localPart { + matches = append(matches, group) + } + } + if len(matches) == 0 { + return fmt.Errorf("worksmobile orgunit local-part match not found: %s", localPart) + } + if len(matches) > 1 { + return fmt.Errorf("worksmobile orgunit local-part match is ambiguous: %s", localPart) + } + remote := matches[0] + if strings.TrimSpace(remote.ID) == "" { + return fmt.Errorf("worksmobile orgunit id is missing for local-part: %s", localPart) + } + if strings.TrimSpace(remote.ExternalID) != "" { + if remote.ExternalID == payload.OrgUnitExternalKey { + return nil + } + return fmt.Errorf("worksmobile orgunit external key already exists for local-part %s: %s", localPart, remote.ExternalID) + } + if delay := c.orgUnitWriteDelay(); delay > 0 { + time.Sleep(delay) + } + patch := NewWorksmobileOrgUnitPatchPayload(payload) + if patch.Email == "" { + patch.Email = remote.Email + } + return c.PatchOrgUnit(ctx, remote.ID, patch) +} + +func (c *WorksmobileHTTPClient) PatchOrgUnit(ctx context.Context, orgUnitID string, payload WorksmobileOrgUnitPatchPayload) error { + orgUnitID = strings.TrimSpace(orgUnitID) + if orgUnitID == "" { + return fmt.Errorf("worksmobile orgunit id is required") + } + return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload) +} + +func (c *WorksmobileHTTPClient) ClearOrgUnitExternalKey(ctx context.Context, orgUnitID string, domainID int64) error { + orgUnitID = strings.TrimSpace(orgUnitID) + if orgUnitID == "" { + return fmt.Errorf("worksmobile orgunit id is required") + } + payload := map[string]any{ + "domainId": domainID, + "orgUnitExternalKey": "", + } + return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload) +} + +func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error { + orgUnitID = strings.TrimSpace(orgUnitID) + if orgUnitID == "" { + return fmt.Errorf("worksmobile orgunit id is required") + } + if delay := c.orgUnitWriteDelay(); delay > 0 { + time.Sleep(delay) + } + return c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), nil) +} + func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error { return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload) } @@ -611,6 +710,15 @@ type WorksmobileUserPatchPayload struct { Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"` } +type WorksmobileOrgUnitPatchPayload struct { + DomainID int64 `json:"domainId"` + Email string `json:"email,omitempty"` + OrgUnitName string `json:"orgUnitName,omitempty"` + OrgUnitExternalKey string `json:"orgUnitExternalKey,omitempty"` + ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"` + DisplayOrder int `json:"displayOrder,omitempty"` +} + type WorksmobileRemoteUser struct { ID string `json:"id"` ExternalID string `json:"externalId"` @@ -631,13 +739,15 @@ type WorksmobileRemoteUser struct { } type WorksmobileRemoteGroup struct { - ID string `json:"id"` - ExternalID string `json:"externalId"` - DisplayName string `json:"displayName"` - DomainID int64 `json:"domainId"` - DomainName string `json:"domainName"` - ParentID string `json:"parentId"` - ParentName string `json:"parentName"` + ID string `json:"id"` + ExternalID string `json:"externalId"` + DisplayName string `json:"displayName"` + Email string `json:"email,omitempty"` + MailLocalPart string `json:"mailLocalPart,omitempty"` + DomainID int64 `json:"domainId"` + DomainName string `json:"domainName"` + ParentID string `json:"parentId"` + ParentName string `json:"parentName"` } func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSCIMUserPayload { @@ -681,6 +791,17 @@ func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileU } } +func NewWorksmobileOrgUnitPatchPayload(payload WorksmobileOrgUnitPayload) WorksmobileOrgUnitPatchPayload { + return WorksmobileOrgUnitPatchPayload{ + DomainID: payload.DomainID, + Email: strings.TrimSpace(payload.Email), + OrgUnitName: strings.TrimSpace(payload.OrgUnitName), + OrgUnitExternalKey: strings.TrimSpace(payload.OrgUnitExternalKey), + ParentOrgUnitID: strings.TrimSpace(payload.ParentOrgUnitID), + DisplayOrder: payload.DisplayOrder, + } +} + func worksmobileSCIMPreferredLanguage(locale string) string { locale = strings.TrimSpace(locale) if locale == "" { @@ -716,10 +837,13 @@ func parseWorksmobileRemoteUser(resource map[string]any) WorksmobileRemoteUser { } func parseWorksmobileRemoteGroup(resource map[string]any) WorksmobileRemoteGroup { + email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName") group := WorksmobileRemoteGroup{ - ID: stringFromMap(resource, "id"), - ExternalID: stringFromMap(resource, "externalId"), - DisplayName: stringFromMap(resource, "displayName"), + ID: stringFromMap(resource, "id"), + ExternalID: stringFromMap(resource, "externalId"), + DisplayName: stringFromMap(resource, "displayName"), + Email: email, + MailLocalPart: worksmobileMailLocalPart(email), } group.ParentID, group.ParentName = parseWorksmobileParentOrgUnit(resource) return group @@ -751,15 +875,29 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse } func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup { + email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName") return WorksmobileRemoteGroup{ - ID: firstStringFromMap(resource, "orgUnitId", "id"), - ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"), - DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"), - ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"), - ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"), + ID: firstStringFromMap(resource, "orgUnitId", "id"), + ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"), + DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"), + Email: email, + MailLocalPart: worksmobileMailLocalPart(email), + ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"), + ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"), } } +func worksmobileMailLocalPart(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + return "" + } + if at := strings.Index(normalized, "@"); at >= 0 { + normalized = normalized[:at] + } + return strings.TrimSpace(normalized) +} + func parseWorksmobileDirectoryUserName(resource map[string]any) string { if value := firstStringFromMap(resource, "displayName", "name"); value != "" { return value @@ -969,3 +1107,13 @@ func (c *WorksmobileHTTPClient) currentTime() time.Time { } return time.Now() } + +func (c *WorksmobileHTTPClient) orgUnitWriteDelay() time.Duration { + if c.OrgUnitWriteDelay < 0 { + return 0 + } + if c.OrgUnitWriteDelay > 0 { + return c.OrgUnitWriteDelay + } + return 1100 * time.Millisecond +} diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go index 087e346d..86db9dc3 100644 --- a/backend/internal/service/worksmobile_client_test.go +++ b/backend/internal/service/worksmobile_client_test.go @@ -262,6 +262,68 @@ func TestWorksmobileHTTPClientListGroupsUsesDirectoryAPIFirst(t *testing.T) { require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path) } +func TestWorksmobileHTTPClientUpsertOrgUnitBackfillsExternalKeyByMailLocalPart(t *testing.T) { + transport := &captureRoundTripper{ + responses: []captureResponse{ + {statusCode: http.StatusConflict, body: `{"code":"CONFLICT"}`}, + {statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitName":"기술개발센터","email":"tech-dev-center@samaneng.com"}],"responseMetaData":{}}`}, + {statusCode: http.StatusOK, body: `{}`}, + }, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + DomainIDs: []int64{300285955}, + HTTPClient: &http.Client{Transport: transport}, + OrgUnitWriteDelay: -1, + } + + err := client.UpsertOrgUnit(context.Background(), WorksmobileOrgUnitPayload{ + DomainID: 300285955, + OrgUnitName: "기술개발센터", + OrgUnitExternalKey: "tenant-tech-dev-center", + DisplayOrder: 0, + }, "tech-dev-center") + + require.NoError(t, err) + require.Len(t, transport.requests, 3) + require.Equal(t, http.MethodPost, transport.requests[0].Method) + require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path) + require.Equal(t, http.MethodGet, transport.requests[1].Method) + require.Equal(t, "/v1.0/orgunits", transport.requests[1].URL.Path) + require.Equal(t, http.MethodPatch, transport.requests[2].Method) + require.Equal(t, "/v1.0/orgunits/works-org-1", transport.requests[2].URL.Path) + require.Contains(t, string(transport.requestBodies[1]), `"orgUnitExternalKey":"tenant-tech-dev-center"`) +} + +func TestWorksmobileHTTPClientUpsertOrgUnitTreatsExistingExternalKeyConflictAsSuccess(t *testing.T) { + transport := &captureRoundTripper{ + responses: []captureResponse{ + {statusCode: http.StatusConflict, body: `{"code":"CONFLICT"}`}, + {statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitExternalKey":"tenant-tech-dev-center","orgUnitName":"기술개발센터"}],"responseMetaData":{}}`}, + }, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + DomainIDs: []int64{300285955}, + HTTPClient: &http.Client{Transport: transport}, + } + + err := client.UpsertOrgUnit(context.Background(), WorksmobileOrgUnitPayload{ + DomainID: 300285955, + OrgUnitName: "기술개발센터", + OrgUnitExternalKey: "tenant-tech-dev-center", + }, "") + + require.NoError(t, err) + require.Len(t, transport.requests, 3) + require.Equal(t, http.MethodPost, transport.requests[0].Method) + require.Equal(t, http.MethodGet, transport.requests[1].Method) + require.Equal(t, http.MethodPatch, transport.requests[2].Method) + require.Contains(t, string(transport.requestBodies[1]), `"orgUnitExternalKey":"tenant-tech-dev-center"`) +} + func TestWorksmobileLiveJWTTokenExchange(t *testing.T) { if os.Getenv("WORKSMOBILE_LIVE_JWT_TOKEN_EXCHANGE") != "1" { t.Skip("live Worksmobile token exchange is disabled") @@ -486,6 +548,35 @@ func TestCompareWorksmobileGroupsIncludesWorksOnlyRowsWithoutExternalIDWhenInclu require.Equal(t, "WORKS 전용 조직", items[0].WorksmobileName) } +func TestCompareWorksmobileGroupsMatchesBySlugLocalPartWhenExternalIDMissing(t *testing.T) { + localTenants := []domain.Tenant{ + { + ID: "tenant-tech-dev-center", + Slug: "tech-dev-center", + Name: "기술개발센터", + Type: domain.TenantTypeOrganization, + }, + } + remoteGroups := []WorksmobileRemoteGroup{ + { + ID: "works-org-1", + DisplayName: "기술개발센터", + Email: "tech-dev-center@samaneng.com", + MailLocalPart: "tech-dev-center", + }, + } + + diffOnly := compareWorksmobileGroups(localTenants, remoteGroups, false) + all := compareWorksmobileGroups(localTenants, remoteGroups, true) + + require.Empty(t, diffOnly) + require.Len(t, all, 1) + require.Equal(t, "matched", all[0].Status) + require.Equal(t, "tenant-tech-dev-center", all[0].BaronID) + require.Equal(t, "works-org-1", all[0].WorksmobileID) + require.Empty(t, all[0].ExternalKey) +} + func TestParseWorksmobileRemoteUserUsesUserNameEmailWhenEmailsAreEmpty(t *testing.T) { user := parseWorksmobileRemoteUser(map[string]any{ "id": "works-1", @@ -564,6 +655,17 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing require.True(t, *user.PrimaryOrgUnitIsManager) } +func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) { + group := parseWorksmobileDirectoryGroup(map[string]any{ + "orgUnitId": "works-org-1", + "orgUnitName": "기술개발센터", + "email": "tech-dev-center@samaneng.com", + }) + + require.Equal(t, "tech-dev-center@samaneng.com", group.Email) + require.Equal(t, "tech-dev-center", group.MailLocalPart) +} + type fakeWorksmobileOutboxRepo struct { ready []domain.WorksmobileOutbox created []domain.WorksmobileOutbox @@ -609,9 +711,10 @@ func (f *fakeWorksmobileOutboxRepo) MarkFailed(ctx context.Context, id string, m } type fakeWorksmobileDirectoryClient struct { - createdOrgUnits []WorksmobileOrgUnitPayload - createdUsers []WorksmobileUserPayload - deletedUsers []string + createdOrgUnits []WorksmobileOrgUnitPayload + createdUsers []WorksmobileUserPayload + deletedUsers []string + orgUnitMatchKeys []string } type captureRoundTripper struct { @@ -679,6 +782,12 @@ func (f *fakeWorksmobileDirectoryClient) CreateOrgUnit(ctx context.Context, payl return nil } +func (f *fakeWorksmobileDirectoryClient) UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error { + f.createdOrgUnits = append(f.createdOrgUnits, payload) + f.orgUnitMatchKeys = append(f.orgUnitMatchKeys, matchLocalPart) + return nil +} + func (f *fakeWorksmobileDirectoryClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error { f.createdUsers = append(f.createdUsers, payload) return nil diff --git a/backend/internal/service/worksmobile_live_flow_test.go b/backend/internal/service/worksmobile_live_flow_test.go index ff2a9cc5..81f5b50d 100644 --- a/backend/internal/service/worksmobile_live_flow_test.go +++ b/backend/internal/service/worksmobile_live_flow_test.go @@ -4,8 +4,13 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" + "encoding/csv" "fmt" + "io" + "net/http" + "net/url" "os" + "sort" "strings" "testing" "time" @@ -105,6 +110,444 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) { require.True(t, foundSamanOrgUnit) } +func TestWorksmobileLiveSamanOrgUnitProvisioning(t *testing.T) { + if os.Getenv("WORKSMOBILE_LIVE_SAMAN_ORGUNIT_PROVISIONING") != "1" { + t.Skip("live Worksmobile Saman orgunit provisioning is disabled") + } + runWorksmobileLiveCompanyOrgUnitProvisioning(t, "saman", "SAMAN_DOMAIN_ID", nil) +} + +func TestWorksmobileLiveGPDTDCOrgUnitProvisioning(t *testing.T) { + if os.Getenv("WORKSMOBILE_LIVE_GPDTDC_ORGUNIT_PROVISIONING") != "1" { + t.Skip("live Worksmobile GPDTDC orgunit provisioning is disabled") + } + runWorksmobileLiveCompanyOrgUnitProvisioning(t, "gpdtdc", "GPDTDC_DOMAIN_ID", map[string]bool{ + "56cd0fd7-b62a-43c0-8db9-74a30468d7cb": true, + }) +} + +func TestWorksmobileLiveSyncHanmacFamilyOrgUnits(t *testing.T) { + if os.Getenv("WORKSMOBILE_LIVE_SYNC_HANMAC_FAMILY_ORGUNITS") != "1" { + t.Skip("live Worksmobile Hanmac family orgunit sync is disabled") + } + ctx := context.Background() + db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) + require.NoError(t, err) + + tenantRepo := repository.NewTenantRepository(db) + userRepo := repository.NewUserRepository(db) + userGroupRepo := repository.NewUserGroupRepository(db) + tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) + client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ + ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), + ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), + PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), + Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), + }) + + root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug) + require.NoError(t, err) + tenants, err := listWorksmobileLiveTenantScope(db, root.ID) + require.NoError(t, err) + tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, tenants...)) + targets := worksmobileLiveOrgUnitTargets(t, tenants, tenantByID, *root) + targetByID := map[string]worksmobileLiveOrgUnitTarget{} + for _, target := range targets { + targetByID[target.Tenant.ID] = target + } + + remoteGroups, err := client.ListGroups(ctx) + require.NoError(t, err) + remoteByExternalID, duplicateExternalKeys := worksmobileLiveRemoteByExternalID(remoteGroups) + require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") + + for _, target := range sortWorksmobileLiveTargetsTopologically(targets, tenantByID) { + remote, found := remoteByExternalID[target.Tenant.ID] + if found && remote.DomainID != target.Payload.DomainID { + require.Failf(t, "external key is attached to a different Worksmobile domain", "slug=%s external=%s currentDomain=%d expectedDomain=%d", target.Tenant.Slug, target.Tenant.ID, remote.DomainID, target.Payload.DomainID) + } + if !found { + remote, found = findWorksmobileLiveRemoteByPath(remoteGroups, worksmobileLiveRemoteByID(remoteGroups), target.Payload.DomainID, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID)) + } + if found { + t.Logf("PATCH orgunit slug=%s id=%s worksID=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, remote.ID, target.Payload.Email, target.Payload.ParentOrgUnitID) + require.NoError(t, patchWorksmobileLiveOrgUnit(ctx, client, remote.ID, target.Payload)) + } else { + t.Logf("CREATE orgunit slug=%s id=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, target.Payload.Email, target.Payload.ParentOrgUnitID) + require.NoError(t, client.UpsertOrgUnit(ctx, target.Payload, target.Tenant.Slug)) + } + time.Sleep(1100 * time.Millisecond) + + remoteGroups, err = client.ListGroups(ctx) + require.NoError(t, err) + remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups) + require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") + } + + remoteGroups, err = client.ListGroups(ctx) + require.NoError(t, err) + remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups) + require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") + remoteByID := worksmobileLiveRemoteByID(remoteGroups) + for _, target := range targets { + remote, ok := remoteByExternalID[target.Tenant.ID] + require.True(t, ok, "missing Worksmobile orgunit after sync: %s", target.Tenant.Slug) + require.Equal(t, target.Payload.DomainID, remote.DomainID, "domain mismatch: %s", target.Tenant.Slug) + require.Equal(t, target.Tenant.Name, remote.DisplayName, "name mismatch: %s", target.Tenant.Slug) + require.Equal(t, worksmobileMailLocalPart(target.Payload.Email), remote.MailLocalPart, "email local-part mismatch: %s", target.Tenant.Slug) + require.Equal(t, strings.ToLower(strings.TrimSpace(target.Payload.Email)), strings.ToLower(strings.TrimSpace(remote.Email)), "email mismatch: %s", target.Tenant.Slug) + expectedParentID := "" + if parentExternalKey := strings.TrimPrefix(target.Payload.ParentOrgUnitID, "externalKey:"); parentExternalKey != "" && parentExternalKey != target.Payload.ParentOrgUnitID { + parentRemote, ok := remoteByExternalID[parentExternalKey] + require.True(t, ok, "missing Worksmobile parent for %s", target.Tenant.Slug) + expectedParentID = parentRemote.ID + parentTarget, ok := targetByID[parentExternalKey] + require.True(t, ok, "missing Baron parent target for %s", target.Tenant.Slug) + require.Equal(t, worksmobileMailLocalPart(parentTarget.Payload.Email), parentRemote.MailLocalPart, "parent email local-part mismatch: %s", target.Tenant.Slug) + } + require.Equal(t, expectedParentID, remote.ParentID, "parent mismatch: %s", target.Tenant.Slug) + require.Equal(t, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID), worksmobileLiveRemotePath(remoteByID, remote), "path mismatch: %s", target.Tenant.Slug) + } + + extraWithExternalKey, extraWithoutExternalKey := worksmobileLiveExtraRemoteGroups(remoteGroups, targetByID) + t.Logf("SUMMARY synced=%d extraWithExternalKey=%d extraWithoutExternalKey=%d", len(targets), len(extraWithExternalKey), len(extraWithoutExternalKey)) + for _, extra := range extraWithExternalKey { + t.Logf("EXTRA external=%s name=%s email=%s domain=%d", extra.ExternalID, extra.DisplayName, extra.Email, extra.DomainID) + } + for _, extra := range extraWithoutExternalKey { + t.Logf("EXTRA_NO_EXTERNAL id=%s name=%s email=%s domain=%d", extra.ID, extra.DisplayName, extra.Email, extra.DomainID) + } +} + +func TestWorksmobileLiveInspectGPDTDCOrgUnits(t *testing.T) { + if os.Getenv("WORKSMOBILE_LIVE_INSPECT_GPDTDC_ORGUNITS") != "1" { + t.Skip("live Worksmobile GPDTDC orgunit inspection is disabled") + } + ctx := context.Background() + db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) + require.NoError(t, err) + + tenantRepo := repository.NewTenantRepository(db) + userRepo := repository.NewUserRepository(db) + userGroupRepo := repository.NewUserGroupRepository(db) + tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) + client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ + ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), + ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), + PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), + Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), + }) + + gpdtdcTenant, err := tenantService.GetTenantBySlug(ctx, "gpdtdc") + require.NoError(t, err) + tenants, err := listWorksmobileLiveTenantSubtree(db, gpdtdcTenant.ID) + require.NoError(t, err) + gpdtdcDomainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID") + require.True(t, ok, "missing GPDTDC_DOMAIN_ID") + + remoteGroups, err := client.ListGroups(ctx) + require.NoError(t, err) + remoteByExternalID := map[string]WorksmobileRemoteGroup{} + remoteByID := map[string]WorksmobileRemoteGroup{} + gpdtdcRemoteCount := 0 + for _, group := range remoteGroups { + remoteByID[group.ID] = group + if group.DomainID == gpdtdcDomainID { + gpdtdcRemoteCount++ + t.Logf("REMOTE GPDTDC id=%s external=%s name=%s email=%s parent=%s parentName=%s", group.ID, group.ExternalID, group.DisplayName, group.Email, group.ParentID, group.ParentName) + } + if group.ExternalID != "" { + remoteByExternalID[group.ExternalID] = group + } + } + + missing := make([]domain.Tenant, 0) + wrongDomain := make([]WorksmobileRemoteGroup, 0) + for _, tenant := range tenants { + if tenant.ID == "56cd0fd7-b62a-43c0-8db9-74a30468d7cb" { + continue + } + remote, ok := remoteByExternalID[tenant.ID] + if !ok { + missing = append(missing, tenant) + continue + } + if remote.DomainID != gpdtdcDomainID { + wrongDomain = append(wrongDomain, remote) + } + } + for _, tenant := range missing { + t.Logf("MISSING LOCAL id=%s slug=%s name=%s parent=%v", tenant.ID, tenant.Slug, tenant.Name, tenant.ParentID) + } + for _, remote := range wrongDomain { + t.Logf("WRONG DOMAIN external=%s name=%s domainID=%d domainName=%s", remote.ExternalID, remote.DisplayName, remote.DomainID, remote.DomainName) + } + + remoteUsers, err := client.ListUsers(ctx) + require.NoError(t, err) + usersByPrimaryOrg := map[string]int{} + for _, user := range remoteUsers { + if user.DomainID != gpdtdcDomainID || user.PrimaryOrgUnitID == "" { + continue + } + usersByPrimaryOrg[user.PrimaryOrgUnitID]++ + } + for orgID, count := range usersByPrimaryOrg { + group := remoteByID[orgID] + t.Logf("USER PRIMARY ORG orgID=%s count=%d external=%s name=%s email=%s", orgID, count, group.ExternalID, group.DisplayName, group.Email) + } + t.Logf("SUMMARY localOrganizations=%d remoteGPDTDCDomain=%d matchedExternal=%d missing=%d wrongDomain=%d", len(tenants), gpdtdcRemoteCount, len(remoteByExternalID), len(missing), len(wrongDomain)) + require.Empty(t, missing) + require.Empty(t, wrongDomain) +} + +func TestWorksmobileLiveRecoverGPDTDCOrgUnits(t *testing.T) { + if os.Getenv("WORKSMOBILE_LIVE_RECOVER_GPDTDC_ORGUNITS") != "1" { + t.Skip("live Worksmobile GPDTDC orgunit recovery is disabled") + } + ctx := context.Background() + db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) + require.NoError(t, err) + + tenantRepo := repository.NewTenantRepository(db) + userRepo := repository.NewUserRepository(db) + userGroupRepo := repository.NewUserGroupRepository(db) + tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) + client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ + ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), + ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), + PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), + Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), + }) + + gpdtdcTenant, err := tenantService.GetTenantBySlug(ctx, "gpdtdc") + require.NoError(t, err) + tenants, err := listWorksmobileLiveTenantSubtree(db, gpdtdcTenant.ID) + require.NoError(t, err) + csvNodes, err := readWorksmobileLiveOrgCSV("../../../adminfront/gpdtdc_org_slugged.csv") + require.NoError(t, err) + requireWorksmobileLiveBaronCSVMatch(t, tenants, csvNodes) + gpdtdcDomainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID") + require.True(t, ok, "missing GPDTDC_DOMAIN_ID") + + remoteGroups, err := client.ListGroups(ctx) + require.NoError(t, err) + remoteByID := map[string]WorksmobileRemoteGroup{} + remoteByExternalID := map[string]WorksmobileRemoteGroup{} + for _, group := range remoteGroups { + remoteByID[group.ID] = group + if group.ExternalID != "" { + remoteByExternalID[group.ExternalID] = group + } + } + for _, tenant := range tenants { + current, ok := remoteByExternalID[tenant.ID] + if !ok || current.DomainID == gpdtdcDomainID { + continue + } + t.Logf("CLEAR conflicting external key id=%s external=%s name=%s email=%s domain=%d", current.ID, current.ExternalID, current.DisplayName, current.Email, current.DomainID) + if err := client.ClearOrgUnitExternalKey(ctx, current.ID, current.DomainID); err != nil { + legacyPatch := WorksmobileOrgUnitPatchPayload{ + DomainID: current.DomainID, + OrgUnitExternalKey: "legacy-" + current.ID, + } + t.Logf("REKEY conflicting external key id=%s replacement=%s error=%v", current.ID, legacyPatch.OrgUnitExternalKey, err) + require.NoError(t, client.PatchOrgUnit(ctx, current.ID, legacyPatch)) + } + time.Sleep(1100 * time.Millisecond) + } + remoteGroups, err = client.ListGroups(ctx) + require.NoError(t, err) + remoteByID = map[string]WorksmobileRemoteGroup{} + remoteByExternalID = map[string]WorksmobileRemoteGroup{} + for _, group := range remoteGroups { + remoteByID[group.ID] = group + if group.ExternalID != "" { + remoteByExternalID[group.ExternalID] = group + } + } + for _, tenant := range tenants { + current, ok := remoteByExternalID[tenant.ID] + if !ok || current.DomainID == gpdtdcDomainID { + continue + } + legacyPatch := WorksmobileOrgUnitPatchPayload{ + DomainID: current.DomainID, + OrgUnitExternalKey: "legacy-" + current.ID, + } + t.Logf("REKEY still-conflicting external key id=%s replacement=%s", current.ID, legacyPatch.OrgUnitExternalKey) + require.NoError(t, client.PatchOrgUnit(ctx, current.ID, legacyPatch)) + time.Sleep(1100 * time.Millisecond) + } + remoteGroups, err = client.ListGroups(ctx) + require.NoError(t, err) + remoteByID = map[string]WorksmobileRemoteGroup{} + remoteByExternalID = map[string]WorksmobileRemoteGroup{} + for _, group := range remoteGroups { + remoteByID[group.ID] = group + if group.ExternalID != "" { + remoteByExternalID[group.ExternalID] = group + } + } + remoteUsers, err := client.ListUsers(ctx) + require.NoError(t, err) + usersByPrimaryOrg := map[string]int{} + for _, user := range remoteUsers { + if user.DomainID == gpdtdcDomainID && user.PrimaryOrgUnitID != "" { + usersByPrimaryOrg[user.PrimaryOrgUnitID]++ + } + } + + type recoveryTarget struct { + Tenant domain.Tenant + CSV worksmobileLiveCSVOrg + Target WorksmobileRemoteGroup + Bad *WorksmobileRemoteGroup + } + targets := make([]recoveryTarget, 0) + badByID := map[string]WorksmobileRemoteGroup{} + for _, tenant := range tenants { + node, ok := csvNodes[tenant.Slug] + if !ok { + t.Logf("SKIP no CSV node slug=%s name=%s id=%s", tenant.Slug, tenant.Name, tenant.ID) + continue + } + desiredPath := worksmobileLiveCSVPath(csvNodes, tenant.Slug) + target, found := findWorksmobileLiveRemoteByPath(remoteGroups, remoteByID, gpdtdcDomainID, desiredPath) + if !found { + if current, ok := remoteByExternalID[tenant.ID]; ok && current.DomainID == gpdtdcDomainID { + target = current + found = true + } + } + require.True(t, found, "missing recovery target slug=%s path=%s", tenant.Slug, desiredPath) + var bad *WorksmobileRemoteGroup + if current, ok := remoteByExternalID[tenant.ID]; ok && current.DomainID == gpdtdcDomainID && current.ID != target.ID { + currentCopy := current + bad = ¤tCopy + badByID[current.ID] = current + } + targets = append(targets, recoveryTarget{Tenant: tenant, CSV: node, Target: target, Bad: bad}) + } + + badIDs := map[string]bool{} + for id := range badByID { + collectWorksmobileLiveSubtreeIDs(id, remoteGroups, badIDs) + } + badGroups := make([]WorksmobileRemoteGroup, 0, len(badIDs)) + for id := range badIDs { + group := remoteByID[id] + if group.ID == "" { + continue + } + require.Zero(t, usersByPrimaryOrg[id], "refusing to delete orgunit with primary users: %s %s", group.DisplayName, id) + badGroups = append(badGroups, group) + } + sort.SliceStable(badGroups, func(i, j int) bool { + return worksmobileLiveRemoteDepth(remoteByID, badGroups[i]) > worksmobileLiveRemoteDepth(remoteByID, badGroups[j]) + }) + for _, group := range badGroups { + t.Logf("DELETE duplicate id=%s external=%s name=%s email=%s", group.ID, group.ExternalID, group.DisplayName, group.Email) + require.NoError(t, client.DeleteOrgUnit(ctx, group.ID)) + } + + for _, target := range targets { + if badIDs[target.Target.ID] { + continue + } + patch := WorksmobileOrgUnitPatchPayload{ + DomainID: gpdtdcDomainID, + Email: target.CSV.Email, + OrgUnitName: target.CSV.Name, + OrgUnitExternalKey: target.Tenant.ID, + } + t.Logf("PATCH existing id=%s external=%s name=%s email=%s", target.Target.ID, target.Tenant.ID, target.CSV.Name, target.CSV.Email) + require.NoError(t, client.PatchOrgUnit(ctx, target.Target.ID, patch)) + time.Sleep(1100 * time.Millisecond) + } + + remoteGroups, err = client.ListGroups(ctx) + require.NoError(t, err) + remoteByExternalID = map[string]WorksmobileRemoteGroup{} + for _, group := range remoteGroups { + if group.ExternalID != "" { + remoteByExternalID[group.ExternalID] = group + } + } + for _, target := range targets { + remote, ok := remoteByExternalID[target.Tenant.ID] + require.True(t, ok, "missing recovered external key for %s", target.Tenant.Slug) + require.Equal(t, gpdtdcDomainID, remote.DomainID) + require.Equal(t, target.CSV.Name, remote.DisplayName) + } +} + +func runWorksmobileLiveCompanyOrgUnitProvisioning(t *testing.T, companySlug string, domainIDEnvKey string, skipTenantIDs map[string]bool) { + t.Helper() + ctx := context.Background() + db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) + require.NoError(t, err) + + tenantRepo := repository.NewTenantRepository(db) + userRepo := repository.NewUserRepository(db) + userGroupRepo := repository.NewUserGroupRepository(db) + tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) + client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ + ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), + ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), + PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), + Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), + }) + + companyTenant, err := tenantService.GetTenantBySlug(ctx, companySlug) + require.NoError(t, err) + root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug) + require.NoError(t, err) + tenants, err := listWorksmobileLiveTenantSubtree(db, companyTenant.ID) + require.NoError(t, err) + + domainID, ok := worksmobileDomainIDFromEnv(domainIDEnvKey) + require.True(t, ok, "missing %s", domainIDEnvKey) + + tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, append([]domain.Tenant{*companyTenant}, tenants...)...)) + for index, tenant := range sortWorksmobileLiveOrgUnitsTopologically(tenants) { + if skipTenantIDs[tenant.ID] { + continue + } + payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, *companyTenant, root.Config, index+1) + require.NoError(t, err) + payload.DomainID = domainID + if tenant.ParentID != nil && (*tenant.ParentID == companyTenant.ID || skipTenantIDs[*tenant.ParentID]) { + payload.ParentOrgUnitID = "" + } else { + payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) + } + require.NoError(t, client.UpsertOrgUnit(ctx, payload, tenant.Slug), "tenant=%s slug=%s", tenant.Name, tenant.Slug) + time.Sleep(1100 * time.Millisecond) + } + + remoteGroups, err := client.ListGroups(ctx) + require.NoError(t, err) + remoteByExternalID := map[string]WorksmobileRemoteGroup{} + for _, group := range remoteGroups { + if group.ExternalID != "" { + remoteByExternalID[group.ExternalID] = group + } + } + for _, tenant := range tenants { + if skipTenantIDs[tenant.ID] { + continue + } + remote, ok := remoteByExternalID[tenant.ID] + require.True(t, ok, "missing remote orgunit external key for %s %s", tenant.Name, tenant.ID) + require.Equal(t, tenant.Name, remote.DisplayName) + } +} + func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, client *WorksmobileHTTPClient, tenant domain.Tenant) { t.Helper() payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 1) @@ -119,6 +562,435 @@ func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, cl require.NoError(t, err) } +func listWorksmobileLiveTenantSubtree(db *gorm.DB, rootID string) ([]domain.Tenant, error) { + var tenants []domain.Tenant + err := db.Raw(` +with recursive scope as ( + select id, type, parent_id, name, slug, description, status, config, created_at, updated_at, deleted_at + from tenants + where id = ? and deleted_at is null + union all + select t.id, t.type, t.parent_id, t.name, t.slug, t.description, t.status, t.config, t.created_at, t.updated_at, t.deleted_at + from tenants t + join scope on t.parent_id = scope.id + where t.deleted_at is null +) +select * +from scope +where type = ? +order by name, slug +`, rootID, domain.TenantTypeOrganization).Scan(&tenants).Error + return tenants, err +} + +func listWorksmobileLiveTenantScope(db *gorm.DB, rootID string) ([]domain.Tenant, error) { + type tenantIDRow struct { + ID string + } + rows := []tenantIDRow{} + if err := db.Raw(` +with recursive scope as ( + select id, parent_id, created_at + from tenants + where id = ? and deleted_at is null + union all + select t.id, t.parent_id, t.created_at + from tenants t + join scope on t.parent_id = scope.id + where t.deleted_at is null +) +select id +from scope +where id <> ? +order by created_at, id +`, rootID, rootID).Scan(&rows).Error; err != nil { + return nil, err + } + ids := make([]string, 0, len(rows)) + order := map[string]int{} + for index, row := range rows { + ids = append(ids, row.ID) + order[row.ID] = index + } + if len(ids) == 0 { + return nil, nil + } + tenants := []domain.Tenant{} + if err := db.Preload("Domains").Where("id IN ?", ids).Find(&tenants).Error; err != nil { + return nil, err + } + sort.SliceStable(tenants, func(i, j int) bool { + return order[tenants[i].ID] < order[tenants[j].ID] + }) + return tenants, nil +} + +type worksmobileLiveOrgUnitTarget struct { + Tenant domain.Tenant + Payload WorksmobileOrgUnitPayload +} + +func worksmobileLiveOrgUnitTargets(t *testing.T, tenants []domain.Tenant, tenantByID map[string]domain.Tenant, root domain.Tenant) []worksmobileLiveOrgUnitTarget { + t.Helper() + targets := make([]worksmobileLiveOrgUnitTarget, 0) + seenExternalKeys := map[string]string{} + seenEmails := map[string]string{} + for index, tenant := range tenants { + if !isWorksmobileOrgUnitTenant(tenant, tenantByID) || worksmobileLiveSkipOrgUnitTenant(tenant) { + continue + } + domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID) + payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, domainTenant, root.Config, index+1) + require.NoError(t, err, "payload build failed: %s", tenant.Slug) + payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) + require.NotEmpty(t, payload.Email, "orgunit email is required: %s", tenant.Slug) + if owner, exists := seenExternalKeys[payload.OrgUnitExternalKey]; exists { + require.Failf(t, "duplicate Baron external key", "external=%s owner=%s duplicate=%s", payload.OrgUnitExternalKey, owner, tenant.Slug) + } + seenExternalKeys[payload.OrgUnitExternalKey] = tenant.Slug + normalizedEmail := strings.ToLower(strings.TrimSpace(payload.Email)) + if owner, exists := seenEmails[normalizedEmail]; exists { + require.Failf(t, "duplicate Baron orgunit email", "email=%s owner=%s duplicate=%s", normalizedEmail, owner, tenant.Slug) + } + seenEmails[normalizedEmail] = tenant.Slug + targets = append(targets, worksmobileLiveOrgUnitTarget{Tenant: tenant, Payload: payload}) + } + return targets +} + +func worksmobileLiveSkipOrgUnitTenant(tenant domain.Tenant) bool { + slug := strings.ToLower(strings.TrimSpace(tenant.Slug)) + name := strings.TrimSpace(tenant.Name) + return slug == "nw-admin-gpd" || slug == "su2" || name == "네이버웍스관리용" +} + +func sortWorksmobileLiveTargetsTopologically(targets []worksmobileLiveOrgUnitTarget, tenantByID map[string]domain.Tenant) []worksmobileLiveOrgUnitTarget { + byID := map[string]worksmobileLiveOrgUnitTarget{} + for _, target := range targets { + byID[target.Tenant.ID] = target + } + remaining := append([]worksmobileLiveOrgUnitTarget(nil), targets...) + sort.SliceStable(remaining, func(i, j int) bool { + left := worksmobileLiveTenantOrgPath(remaining[i].Tenant, tenantByID) + right := worksmobileLiveTenantOrgPath(remaining[j].Tenant, tenantByID) + if left != right { + return left < right + } + return remaining[i].Tenant.Slug < remaining[j].Tenant.Slug + }) + done := map[string]bool{} + result := make([]worksmobileLiveOrgUnitTarget, 0, len(remaining)) + for len(remaining) > 0 { + progress := false + next := remaining[:0] + for _, target := range remaining { + parentReady := true + if parentID := strings.TrimPrefix(target.Payload.ParentOrgUnitID, "externalKey:"); parentID != "" && parentID != target.Payload.ParentOrgUnitID { + _, parentInTargets := byID[parentID] + parentReady = !parentInTargets || done[parentID] + } + if parentReady { + result = append(result, target) + done[target.Tenant.ID] = true + progress = true + continue + } + next = append(next, target) + } + if !progress { + result = append(result, next...) + break + } + remaining = next + } + return result +} + +func worksmobileLiveTenantOrgPath(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string { + names := []string{tenant.Name} + current := tenant + seen := map[string]bool{tenant.ID: true} + for current.ParentID != nil && strings.TrimSpace(*current.ParentID) != "" { + parent, ok := tenantByID[*current.ParentID] + if !ok || seen[parent.ID] || !isWorksmobileOrgUnitTenant(parent, tenantByID) || worksmobileLiveSkipOrgUnitTenant(parent) { + break + } + seen[parent.ID] = true + names = append([]string{parent.Name}, names...) + current = parent + } + return strings.Join(names, "/") +} + +func worksmobileLiveRemoteByExternalID(groups []WorksmobileRemoteGroup) (map[string]WorksmobileRemoteGroup, []string) { + result := map[string]WorksmobileRemoteGroup{} + duplicates := []string{} + seenDuplicate := map[string]bool{} + for _, group := range groups { + if group.ExternalID == "" { + continue + } + if _, exists := result[group.ExternalID]; exists { + if !seenDuplicate[group.ExternalID] { + duplicates = append(duplicates, group.ExternalID) + seenDuplicate[group.ExternalID] = true + } + continue + } + result[group.ExternalID] = group + } + sort.Strings(duplicates) + return result, duplicates +} + +func worksmobileLiveRemoteByID(groups []WorksmobileRemoteGroup) map[string]WorksmobileRemoteGroup { + result := map[string]WorksmobileRemoteGroup{} + for _, group := range groups { + result[group.ID] = group + } + return result +} + +func patchWorksmobileLiveOrgUnit(ctx context.Context, client *WorksmobileHTTPClient, orgUnitID string, payload WorksmobileOrgUnitPayload) error { + body := map[string]any{ + "domainId": payload.DomainID, + "email": strings.TrimSpace(payload.Email), + "orgUnitName": strings.TrimSpace(payload.OrgUnitName), + "orgUnitExternalKey": strings.TrimSpace(payload.OrgUnitExternalKey), + "parentOrgUnitId": strings.TrimSpace(payload.ParentOrgUnitID), + "displayOrder": payload.DisplayOrder, + } + return client.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(strings.TrimSpace(orgUnitID)), body) +} + +func worksmobileLiveExtraRemoteGroups(groups []WorksmobileRemoteGroup, targetByID map[string]worksmobileLiveOrgUnitTarget) ([]WorksmobileRemoteGroup, []WorksmobileRemoteGroup) { + extraWithExternalKey := []WorksmobileRemoteGroup{} + extraWithoutExternalKey := []WorksmobileRemoteGroup{} + for _, group := range groups { + if group.ExternalID == "" { + extraWithoutExternalKey = append(extraWithoutExternalKey, group) + continue + } + if _, ok := targetByID[group.ExternalID]; !ok { + extraWithExternalKey = append(extraWithExternalKey, group) + } + } + sort.SliceStable(extraWithExternalKey, func(i, j int) bool { + return extraWithExternalKey[i].DisplayName < extraWithExternalKey[j].DisplayName + }) + sort.SliceStable(extraWithoutExternalKey, func(i, j int) bool { + return extraWithoutExternalKey[i].DisplayName < extraWithoutExternalKey[j].DisplayName + }) + return extraWithExternalKey, extraWithoutExternalKey +} + +func sortWorksmobileLiveOrgUnitsTopologically(tenants []domain.Tenant) []domain.Tenant { + remaining := append([]domain.Tenant(nil), tenants...) + sort.SliceStable(remaining, func(i, j int) bool { + if remaining[i].Name != remaining[j].Name { + return remaining[i].Name < remaining[j].Name + } + return remaining[i].Slug < remaining[j].Slug + }) + done := map[string]bool{} + result := make([]domain.Tenant, 0, len(remaining)) + for len(remaining) > 0 { + progress := false + next := remaining[:0] + for _, tenant := range remaining { + parentReady := tenant.ParentID == nil || *tenant.ParentID == "" || done[*tenant.ParentID] + if !parentReady { + parentInScope := false + for _, candidate := range remaining { + if candidate.ID == *tenant.ParentID { + parentInScope = true + break + } + } + parentReady = !parentInScope + } + if parentReady { + result = append(result, tenant) + done[tenant.ID] = true + progress = true + continue + } + next = append(next, tenant) + } + if !progress { + result = append(result, next...) + break + } + remaining = next + } + return result +} + +type worksmobileLiveCSVOrg struct { + Slug string + Name string + Email string + ParentSlug string +} + +func readWorksmobileLiveOrgCSV(path string) (map[string]worksmobileLiveCSVOrg, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + reader := csv.NewReader(file) + reader.FieldsPerRecord = -1 + header, err := reader.Read() + if err != nil { + return nil, err + } + index := map[string]int{} + for i, value := range header { + index[strings.TrimSpace(value)] = i + } + result := map[string]worksmobileLiveCSVOrg{} + for { + row, err := reader.Read() + if err == io.EOF { + return result, nil + } + if err != nil { + return nil, err + } + email := csvValue(row, index, "메일링 리스트") + slug := worksmobileMailLocalPart(email) + if slug == "" { + continue + } + parentSlug := "" + parent := csvValue(row, index, "상위 조직") + if start := strings.LastIndex(parent, "("); start >= 0 && strings.HasSuffix(parent, ")") { + parentSlug = worksmobileMailLocalPart(parent[start+1 : len(parent)-1]) + } + result[slug] = worksmobileLiveCSVOrg{ + Slug: slug, + Name: csvValue(row, index, "조직명"), + Email: email, + ParentSlug: parentSlug, + } + } +} + +func requireWorksmobileLiveBaronCSVMatch(t *testing.T, tenants []domain.Tenant, csvNodes map[string]worksmobileLiveCSVOrg) { + t.Helper() + tenantSlugs := map[string]domain.Tenant{} + for _, tenant := range tenants { + tenantSlugs[tenant.Slug] = tenant + } + missingInBaron := make([]string, 0) + for slug := range csvNodes { + if _, ok := tenantSlugs[slug]; !ok { + missingInBaron = append(missingInBaron, slug) + } + } + missingInCSV := make([]string, 0) + for _, tenant := range tenants { + if _, ok := csvNodes[tenant.Slug]; !ok { + missingInCSV = append(missingInCSV, tenant.Slug) + } + } + sort.Strings(missingInBaron) + sort.Strings(missingInCSV) + require.Empty(t, missingInBaron, "CSV slugs missing in Baron") + require.Empty(t, missingInCSV, "Baron slugs missing in CSV") +} + +func csvValue(row []string, index map[string]int, key string) string { + i, ok := index[key] + if !ok || i < 0 || i >= len(row) { + return "" + } + return strings.TrimSpace(row[i]) +} + +func worksmobileLiveCSVPath(nodes map[string]worksmobileLiveCSVOrg, slug string) string { + node, ok := nodes[slug] + if !ok { + return slug + } + if node.ParentSlug == "" { + return node.Name + } + parentPath := worksmobileLiveCSVPath(nodes, node.ParentSlug) + if parentPath == "" { + return node.Name + } + return parentPath + "/" + node.Name +} + +func findWorksmobileLiveRemoteByPath(groups []WorksmobileRemoteGroup, byID map[string]WorksmobileRemoteGroup, domainID int64, path string) (WorksmobileRemoteGroup, bool) { + var fallback WorksmobileRemoteGroup + found := false + for _, group := range groups { + if group.DomainID != domainID { + continue + } + if worksmobileLiveRemotePath(byID, group) != path { + continue + } + if group.ExternalID == "" { + return group, true + } + if !found { + fallback = group + found = true + } + } + return fallback, found +} + +func worksmobileLiveRemotePath(byID map[string]WorksmobileRemoteGroup, group WorksmobileRemoteGroup) string { + names := []string{group.DisplayName} + parentID := strings.TrimSpace(group.ParentID) + seen := map[string]bool{group.ID: true} + for parentID != "" && !seen[parentID] { + parent, ok := byID[parentID] + if !ok { + break + } + seen[parentID] = true + if parent.DisplayName != "" { + names = append([]string{parent.DisplayName}, names...) + } + parentID = strings.TrimSpace(parent.ParentID) + } + return strings.Join(names, "/") +} + +func collectWorksmobileLiveSubtreeIDs(rootID string, groups []WorksmobileRemoteGroup, target map[string]bool) { + if target[rootID] { + return + } + target[rootID] = true + for _, group := range groups { + if group.ParentID == rootID { + collectWorksmobileLiveSubtreeIDs(group.ID, groups, target) + } + } +} + +func worksmobileLiveRemoteDepth(byID map[string]WorksmobileRemoteGroup, group WorksmobileRemoteGroup) int { + depth := 0 + parentID := strings.TrimSpace(group.ParentID) + seen := map[string]bool{group.ID: true} + for parentID != "" && !seen[parentID] { + parent, ok := byID[parentID] + if !ok { + break + } + depth++ + seen[parentID] = true + parentID = strings.TrimSpace(parent.ParentID) + } + return depth +} + func worksmobileLiveDSN() string { host := getenvDefault("DB_HOST", "localhost") port := getenvDefault("DB_PORT", "5432") diff --git a/backend/internal/service/worksmobile_mapper.go b/backend/internal/service/worksmobile_mapper.go index 9799fe44..a85341a2 100644 --- a/backend/internal/service/worksmobile_mapper.go +++ b/backend/internal/service/worksmobile_mapper.go @@ -21,6 +21,7 @@ const ( type WorksmobileOrgUnitPayload struct { DomainID int64 `json:"domainId"` OrgUnitName string `json:"orgUnitName"` + Email string `json:"email,omitempty"` OrgUnitExternalKey string `json:"orgUnitExternalKey"` ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"` DisplayOrder int `json:"displayOrder"` @@ -78,6 +79,7 @@ func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainT payload := WorksmobileOrgUnitPayload{ DomainID: domainID, OrgUnitName: strings.TrimSpace(tenant.Name), + Email: buildWorksmobileOrgUnitEmail(tenant, domainTenant), OrgUnitExternalKey: tenant.ID, DisplayOrder: displayOrder, } @@ -90,6 +92,48 @@ func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainT return payload, nil } +func buildWorksmobileOrgUnitEmail(tenant domain.Tenant, domainTenant domain.Tenant) string { + slug := strings.ToLower(strings.TrimSpace(tenant.Slug)) + if slug == "" { + return "" + } + if domainName := worksmobileTenantMailDomain(domainTenant); domainName != "" { + return slug + "@" + domainName + } + for _, candidate := range append([]domain.TenantDomain{}, domainTenant.Domains...) { + domainName := strings.ToLower(strings.TrimSpace(candidate.Domain)) + if domainName != "" { + return slug + "@" + domainName + } + } + for _, candidate := range tenant.Domains { + domainName := strings.ToLower(strings.TrimSpace(candidate.Domain)) + if domainName != "" { + return slug + "@" + domainName + } + } + return "" +} + +func worksmobileTenantMailDomain(tenant domain.Tenant) string { + envKey := strings.TrimSuffix(worksmobileTenantDomainIDEnvKey(tenant), "_DOMAIN_ID") + if domainName := strings.ToLower(strings.TrimSpace(os.Getenv(envKey + "_MAIL_DOMAIN"))); domainName != "" { + return domainName + } + switch envKey { + case "SAMAN": + return "samaneng.com" + case "HANMAC": + return "hanmaceng.co.kr" + case "GPDTDC": + return "baroncs.co.kr" + case "BARONGROUP": + return "brsw.kr" + default: + return "" + } +} + func BuildWorksmobileUserPayload(user domain.User, tenant domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) { return BuildWorksmobileUserPayloadForDomainTenant(user, tenant, tenant, rootConfig) } diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go index 5f74c2f8..98c245ff 100644 --- a/backend/internal/service/worksmobile_mapper_test.go +++ b/backend/internal/service/worksmobile_mapper_test.go @@ -13,6 +13,7 @@ func TestBuildWorksmobileOrgUnitPayloadUsesTenantExternalKeyAndEnvDomainClassifi parentID := "11111111-1111-1111-1111-111111111111" tenant := domain.Tenant{ ID: "22222222-2222-2222-2222-222222222222", + Slug: "tech-dev-center", Name: "Saman Engineering", ParentID: &parentID, Domains: []domain.TenantDomain{ @@ -32,11 +33,29 @@ func TestBuildWorksmobileOrgUnitPayloadUsesTenantExternalKeyAndEnvDomainClassifi require.NoError(t, err) require.Equal(t, int64(1001), payload.DomainID) require.Equal(t, "Saman Engineering", payload.OrgUnitName) + require.Equal(t, "tech-dev-center@samaneng.com", payload.Email) require.Equal(t, tenant.ID, payload.OrgUnitExternalKey) require.Equal(t, "externalKey:"+parentID, payload.ParentOrgUnitID) require.Equal(t, 7, payload.DisplayOrder) } +func TestBuildWorksmobileOrgUnitPayloadUsesWorksmobileMailDomainForBarongroup(t *testing.T) { + t.Setenv("BARONGROUP_DOMAIN_ID", "1004") + tenant := domain.Tenant{ + ID: "11111111-1111-1111-1111-111111111111", + Slug: "jangheon", + Name: "(주)장헌", + Type: domain.TenantTypeCompany, + Domains: []domain.TenantDomain{{Domain: "jangheon.com"}}, + } + + payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, tenant, nil, 1) + + require.NoError(t, err) + require.Equal(t, int64(1004), payload.DomainID) + require.Equal(t, "jangheon@brsw.kr", payload.Email) +} + func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *testing.T) { rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" payload := WorksmobileOrgUnitPayload{ParentOrgUnitID: "externalKey:" + rootID} diff --git a/backend/internal/service/worksmobile_relay_worker.go b/backend/internal/service/worksmobile_relay_worker.go index 1391cd8b..f62e6dad 100644 --- a/backend/internal/service/worksmobile_relay_worker.go +++ b/backend/internal/service/worksmobile_relay_worker.go @@ -89,11 +89,7 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil { return err } - err := w.client.CreateOrgUnit(ctx, payload) - if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == 409 { - return nil - } - return err + return w.client.UpsertOrgUnit(ctx, payload, stringValue(job.Payload["matchLocalPart"])) case domain.WorksmobileResourceUser: switch job.Action { case domain.WorksmobileActionUpsert: diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index a2f495da..42919724 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -258,7 +258,10 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI ResourceID: tenant.ID, Action: domain.WorksmobileActionUpsert, DedupeKey: "orgunit:upsert:" + tenant.ID, - Payload: domain.JSONMap{"request": payload}, + Payload: domain.JSONMap{ + "request": payload, + "matchLocalPart": tenant.Slug, + }, } if err := s.outboxRepo.Create(ctx, item); err != nil { return nil, err @@ -392,7 +395,10 @@ func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Contex ResourceID: tenant.ID, Action: domain.WorksmobileActionUpsert, DedupeKey: "orgunit:upsert:" + tenant.ID, - Payload: domain.JSONMap{"request": payload}, + Payload: domain.JSONMap{ + "request": payload, + "matchLocalPart": tenant.Slug, + }, }) } @@ -596,8 +602,7 @@ func isWorksmobileUserScopeTenant(tenant domain.Tenant) bool { func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant { current := tenant for { - envKey := worksmobileTenantDomainIDEnvKey(current) - if envKey != "BARONGROUP_DOMAIN_ID" || current.Type == domain.TenantTypeCompany { + if current.Type == domain.TenantTypeCompany || len(current.Domains) > 0 { return current } parentID := worksmobileTenantParentID(current) @@ -635,8 +640,10 @@ func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant payload.ParentOrgUnitID = "" } if tenant.ParentID != nil { - if parent, ok := tenantByID[*tenant.ParentID]; ok && parent.Slug == "baron-group" { - payload.ParentOrgUnitID = "" + if parent, ok := tenantByID[*tenant.ParentID]; ok { + if parent.Slug == "baron-group" || !isWorksmobileOrgUnitTenant(parent, tenantByID) { + payload.ParentOrgUnitID = "" + } } } return payload @@ -785,14 +792,27 @@ func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]dom func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem { remoteByExternalID := map[string]WorksmobileRemoteGroup{} + remoteByMailLocalPart := map[string]WorksmobileRemoteGroup{} + ambiguousMailLocalParts := map[string]bool{} for _, remote := range remoteGroups { if remote.ExternalID != "" { remoteByExternalID[remote.ExternalID] = remote } + if remote.ExternalID == "" && remote.MailLocalPart != "" { + if _, exists := remoteByMailLocalPart[remote.MailLocalPart]; exists { + delete(remoteByMailLocalPart, remote.MailLocalPart) + ambiguousMailLocalParts[remote.MailLocalPart] = true + continue + } + if !ambiguousMailLocalParts[remote.MailLocalPart] { + remoteByMailLocalPart[remote.MailLocalPart] = remote + } + } } tenantByID := worksmobileTenantByID(localTenants) localByID := map[string]domain.Tenant{} ignoredLocalByID := map[string]bool{} + matchedRemoteIDs := map[string]bool{} result := make([]WorksmobileComparisonItem, 0) for _, tenant := range localTenants { if !isWorksmobileOrgUnitTenant(tenant, tenantByID) { @@ -801,7 +821,11 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works } localByID[tenant.ID] = tenant remote, matched := remoteByExternalID[tenant.ID] + if !matched { + remote, matched = remoteByMailLocalPart[worksmobileMailLocalPart(tenant.Slug)] + } if matched && !includeMatched { + matchedRemoteIDs[remote.ID] = true continue } item := WorksmobileComparisonItem{ @@ -817,20 +841,26 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works item.WorksmobileID = remote.ID item.ExternalKey = remote.ExternalID item.WorksmobileName = remote.DisplayName + item.WorksmobileEmail = remote.Email item.WorksmobileDomainID = remote.DomainID item.WorksmobileDomainName = remote.DomainName item.WorksmobileParentID = remote.ParentID item.WorksmobileParentName = remote.ParentName + matchedRemoteIDs[remote.ID] = true } result = append(result, item) } for _, remote := range remoteGroups { + if matchedRemoteIDs[remote.ID] { + continue + } if remote.ExternalID == "" { result = append(result, WorksmobileComparisonItem{ ResourceType: "GROUP", WorksmobileID: remote.ID, ExternalKey: remote.ExternalID, WorksmobileName: remote.DisplayName, + WorksmobileEmail: remote.Email, WorksmobileDomainID: remote.DomainID, WorksmobileDomainName: remote.DomainName, WorksmobileParentID: remote.ParentID, @@ -848,6 +878,7 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works WorksmobileID: remote.ID, ExternalKey: remote.ExternalID, WorksmobileName: remote.DisplayName, + WorksmobileEmail: remote.Email, WorksmobileDomainID: remote.DomainID, WorksmobileDomainName: remote.DomainName, WorksmobileParentID: remote.ParentID, diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index 06dde9ad..263ff474 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -258,7 +258,43 @@ func TestWorksmobileSyncServiceEnqueuesOrganizationOrgUnitSync(t *testing.T) { require.Len(t, outboxRepo.created, 1) request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) require.Equal(t, organizationID, request.OrgUnitExternalKey) - require.Equal(t, "externalKey:"+companyID, request.ParentOrgUnitID) + require.Empty(t, request.ParentOrgUnitID) +} + +func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) { + t.Setenv("GPDTDC_DOMAIN_ID", "1003") + rootID := "root-tenant" + companyID := "company-tenant" + organizationID := "organization-tenant" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "한맥가족", + } + company := domain.Tenant{ + ID: companyID, + Slug: "gpdtdc", + Name: "총괄기획&기술개발센터", + Type: domain.TenantTypeCompany, + ParentID: &rootID, + Domains: []domain.TenantDomain{{Domain: "baroncs.co.kr"}}, + } + organization := domain.Tenant{ + ID: organizationID, + Slug: "gpd", + Name: "총괄기획실", + Type: domain.TenantTypeOrganization, + ParentID: &companyID, + } + tenantByID := worksmobileTenantByID([]domain.Tenant{root, company, organization}) + + domainTenant := worksmobileDomainClassificationTenant(organization, tenantByID) + payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(organization, domainTenant, nil, 1) + + require.NoError(t, err) + require.Equal(t, companyID, domainTenant.ID) + require.Equal(t, int64(1003), payload.DomainID) + require.Equal(t, "gpd@baroncs.co.kr", payload.Email) } func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T) { diff --git a/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts b/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts new file mode 100644 index 00000000..28d69c14 --- /dev/null +++ b/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + getHanmacFamilyTenantOrderRank, + orderHanmacFamilyChildren, + orderHanmacFamilyTenants, +} from "./hanmacFamilyOrder"; + +function tenant(name: string, slug: string) { + return { name, slug }; +} + +describe("hanmac family organization order", () => { + it("orders the top hanmac-family siblings by policy", () => { + const ordered = orderHanmacFamilyTenants([ + tenant("바론그룹", "baron-group"), + tenant("한맥기술", "hanmac"), + tenant("삼안", "saman"), + tenant("총괄기획&기술개발센터", "gpdtdc"), + ]); + + expect(ordered.map((item) => item.name)).toEqual([ + "총괄기획&기술개발센터", + "삼안", + "한맥기술", + "바론그룹", + ]); + }); + + it("keeps hanmac-family as the root before ordered descendants", () => { + const family = tenant("한맥가족", "hanmac-family"); + const children = orderHanmacFamilyChildren(family, [ + tenant("바론그룹", "baron-group"), + tenant("총괄기획&기술개발센터", "gpdtdc"), + tenant("삼안", "saman"), + tenant("한맥기술", "hanmac"), + ]); + + expect([family, ...children].map((item) => item.name)).toEqual([ + "한맥가족", + "총괄기획&기술개발센터", + "삼안", + "한맥기술", + "바론그룹", + ]); + }); + + it("does not rank generic technical centers as GPDTDC", () => { + expect( + getHanmacFamilyTenantOrderRank( + tenant("기술개발센터", "rnd-center"), + ), + ).toBe(Number.MAX_SAFE_INTEGER); + }); +}); diff --git a/orgfront/src/features/orgchart/hanmacFamilyOrder.ts b/orgfront/src/features/orgchart/hanmacFamilyOrder.ts new file mode 100644 index 00000000..0a34dc8c --- /dev/null +++ b/orgfront/src/features/orgchart/hanmacFamilyOrder.ts @@ -0,0 +1,65 @@ +export type HanmacFamilyOrderTenant = { + name: string; + slug: string; +}; + +export const HANMAC_FAMILY_ROOT_SLUG = "hanmac-family"; + +export const HANMAC_FAMILY_TENANT_ORDER = [ + "gpdtdc", + "saman", + "hanmac", + "baron-group", +] as const; + +function normalizedTenantText(tenant: HanmacFamilyOrderTenant) { + return `${tenant.slug} ${tenant.name}`.trim().toLowerCase(); +} + +export function isHanmacFamilyRootTenant(tenant: HanmacFamilyOrderTenant) { + return ( + tenant.slug.toLowerCase() === HANMAC_FAMILY_ROOT_SLUG || + tenant.name.includes("한맥가족") + ); +} + +export function getHanmacFamilyTenantOrderRank( + tenant: HanmacFamilyOrderTenant, +) { + const text = normalizedTenantText(tenant); + if (text.includes("gpdtdc") || text.includes("총괄기획")) return 0; + if (text.includes("saman") || text.includes("삼안")) return 1; + if ( + (text.includes("hanmac") || text.includes("한맥기술")) && + !isHanmacFamilyRootTenant(tenant) + ) { + return 2; + } + if (text.includes("baron-group") || text.includes("바론그룹")) return 3; + return Number.MAX_SAFE_INTEGER; +} + +export function compareHanmacFamilyTenants( + a: T, + b: T, +) { + const rankDiff = + getHanmacFamilyTenantOrderRank(a) - getHanmacFamilyTenantOrderRank(b); + if (rankDiff !== 0) return rankDiff; + return a.name.localeCompare(b.name); +} + +export function orderHanmacFamilyTenants( + tenants: readonly T[], +) { + return [...tenants].sort(compareHanmacFamilyTenants); +} + +export function orderHanmacFamilyChildren( + parent: HanmacFamilyOrderTenant, + children: readonly T[], +) { + return isHanmacFamilyRootTenant(parent) + ? orderHanmacFamilyTenants(children) + : [...children]; +} diff --git a/orgfront/src/features/orgchart/pickerTree.test.ts b/orgfront/src/features/orgchart/pickerTree.test.ts index 8faf3dc8..c1613a31 100644 --- a/orgfront/src/features/orgchart/pickerTree.test.ts +++ b/orgfront/src/features/orgchart/pickerTree.test.ts @@ -51,6 +51,40 @@ describe("buildOrgPickerTree", () => { ]); }); + it("orders hanmac-family children by the shared organization policy", () => { + const tenants = [ + tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"), + tenant( + "baron-group-id", + "COMPANY_GROUP", + "바론그룹", + "baron-group", + "hanmac-family-id", + ), + tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"), + tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"), + tenant( + "gpdtdc-id", + "ORGANIZATION", + "총괄기획&기술개발센터", + "gpdtdc", + "hanmac-family-id", + ), + ]; + + const tree = buildOrgPickerTree({ + tenants, + users: [] satisfies UserSummary[], + }); + + expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([ + "총괄기획&기술개발센터", + "삼안", + "한맥기술", + "바론그룹", + ]); + }); + it("scopes descendant filtering by tenant slug", () => { const tenants = [ tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"), diff --git a/orgfront/src/features/orgchart/pickerTree.ts b/orgfront/src/features/orgchart/pickerTree.ts index 3dc1f71b..3a367235 100644 --- a/orgfront/src/features/orgchart/pickerTree.ts +++ b/orgfront/src/features/orgchart/pickerTree.ts @@ -1,5 +1,6 @@ import type { TenantSummary, UserSummary } from "../../lib/adminApi"; import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree"; +import { orderHanmacFamilyChildren } from "./hanmacFamilyOrder"; import type { OrgPickerTreeNode } from "./pickerTypes"; import { filterTenantsByVisibility } from "./tenantVisibility"; import { getOrgChartUserDisplayName } from "./userDisplay"; @@ -50,9 +51,10 @@ function tenantToPickerNode( tenant: TenantNode, usersBySlug: Map, ): OrgPickerTreeNode { - const tenantChildren = tenant.children.map((child) => - tenantToPickerNode(child, usersBySlug), - ); + const tenantChildren = orderHanmacFamilyChildren( + tenant, + tenant.children, + ).map((child) => tenantToPickerNode(child, usersBySlug)); const userChildren = (usersBySlug.get(tenant.slug.toLowerCase()) || []).map( (user) => ({ type: "user" as const, @@ -150,9 +152,10 @@ export function buildOrgPickerTree({ if (!groupNode) return { roots: [], companies: [], companyGroupId: "" }; - const companies = groupNode.children.filter( - (node) => node.type === "COMPANY", - ); + const companies = orderHanmacFamilyChildren( + groupNode, + groupNode.children, + ).filter((node) => node.type === "COMPANY"); const scopedRoot = tenantId ? findTenantNode([groupNode], tenantId) : groupNode; diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx index b2ac2da9..64d94410 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx @@ -10,6 +10,10 @@ import { fetchUsers, } from "../../../lib/adminApi"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; +import { + orderHanmacFamilyChildren, + orderHanmacFamilyTenants, +} from "../hanmacFamilyOrder"; import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility"; import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay"; @@ -565,7 +569,10 @@ function buildOrgNode( ? 0 : inheritedCompanyColorDepth + 1; const members = usersMap.get(slug) || []; - const children = tenantNode.children.map((child) => + const children = orderHanmacFamilyChildren( + tenantNode, + tenantNode.children, + ).map((child) => buildOrgNode( child, usersMap, @@ -1018,33 +1025,14 @@ function collectOrgSelectionDescendants( ]); } -function getOrgSelectionPolicyRank(node: TenantNode) { - const text = `${node.slug} ${node.name}`.toLowerCase(); - if (text.includes("gpdtdc") || text.includes("총괄기획")) return 1; - if (text.includes("saman") || text.includes("삼안")) return 2; - if ( - (text.includes("hanmac") || text.includes("한맥기술")) && - !text.includes("hanmac-family") - ) { - return 3; - } - if (text.includes("baron") || text.includes("바론")) return 4; - return 100; -} - export function buildOrgSelectionOptions( familyRoot: TenantNode | null, ): OrgSelectionOption[] { - return (familyRoot?.children ?? []) - .filter((node) => + return orderHanmacFamilyTenants( + (familyRoot?.children ?? []).filter((node) => ["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes(node.type), - ) - .sort((a, b) => { - const rankDiff = - getOrgSelectionPolicyRank(a) - getOrgSelectionPolicyRank(b); - if (rankDiff !== 0) return rankDiff; - return a.name.localeCompare(b.name); - }) + ), + ) .map((node) => ({ descendants: collectOrgSelectionDescendants(node, 2), id: node.id, diff --git a/orgfront/tests/orgchart-picker.spec.ts b/orgfront/tests/orgchart-picker.spec.ts index ce17f7d4..009763d6 100644 --- a/orgfront/tests/orgchart-picker.spec.ts +++ b/orgfront/tests/orgchart-picker.spec.ts @@ -303,6 +303,68 @@ test("picker defaults to the hanmac-family company-group when no tenant id is su await expect(picker.getByText("Wrong Group", { exact: true })).toHaveCount(0); }); +test("embed preview picker orders hanmac-family tenants by the shared policy", async ({ + page, +}) => { + await page.unroute("**/api/v1/admin/tenants**"); + await page.unroute("**/api/v1/admin/users**"); + + const tenants = [ + tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"), + tenant( + "baron-group-id", + "COMPANY_GROUP", + "바론그룹", + "baron-group", + "hanmac-family-id", + ), + tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"), + tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"), + tenant( + "gpdtdc-id", + "ORGANIZATION", + "총괄기획&기술개발센터", + "gpdtdc", + "hanmac-family-id", + ), + ]; + + await page.route("**/api/v1/admin/tenants**", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + items: tenants, + total: tenants.length, + limit: 10000, + offset: 0, + }), + }); + }); + await page.route("**/api/v1/admin/users**", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + items: [], + total: 0, + limit: 5000, + offset: 0, + }), + }); + }); + + await page.goto(withShareToken("/embed-preview?select=tenant")); + + await expect( + page.frameLocator("iframe").getByTestId("org-picker-node-name-tenant"), + ).toHaveText([ + "한맥가족", + "총괄기획&기술개발센터", + "삼안", + "한맥기술", + "바론그룹", + ]); +}); + test("picker displays user names with grade and optional position", async ({ page, }) => {
{helpText}
+ {t( + "msg.admin.tenants.parent.local_picker_empty", + "선택할 수 있는 테넌트가 없습니다.", + )} +
- {t( - "msg.admin.tenants.create.form.domains_help", - "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.", - )} -
+ {t( + "msg.admin.tenants.create.form.domains_help", + "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.", + )} +
+ {tenantQuery.data.id} +
{t( "ui.admin.tenants.detail.header_subtitle", diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index f78902ab..676981bf 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -141,6 +141,14 @@ function resolveDefaultImportParentRef( tenants: TenantSummary[], ) { if (preview.row.parentTenantId) { + const parentPreview = previewRows.find( + (candidate) => + candidate.row.rowNumber !== preview.row.rowNumber && + candidate.row.tenantId === preview.row.parentTenantId, + ); + if (parentPreview) { + return previewParentRef(parentPreview.row.rowNumber); + } return tenantParentRef(preview.row.parentTenantId); } if (!preview.row.parentTenantSlug) { diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 6140ae24..785bd1f0 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -288,22 +288,82 @@ export function TenantProfilePage() {