chore: snapshot local state before dev merge

This commit is contained in:
2026-06-17 21:25:42 +09:00
parent b2808759d2
commit 49560e8a8c
107 changed files with 8958 additions and 939 deletions
+22 -8
View File
@@ -40,6 +40,20 @@ baron_sso/
* AdminFront: 사용자 관리 등 Admin 기능
* DevFront: RP 관리 등 개발자 기능
## 개발 실행 정책
`make dev`는 로컬 개발용 실행 모드이며, React 기반 `adminfront`, `devfront`, `orgfront`는 모두 Vite HMR 모드로 동작해야 합니다. 이 세 서비스는 Docker Compose에서 Dockerfile `dev` target을 사용하고 `/workspace/<app>` bind mount 위에서 `npm run dev -- --host 0.0.0.0`로 실행합니다. `make dev` 경로에서 production `dist``serve_frontend_prod.mjs`로 정적 서빙하면 안 됩니다.
현재 개발 포트는 다음과 같습니다.
- AdminFront: `http://localhost:5173`
- DevFront: `http://localhost:5174`
- OrgFront: `http://localhost:5175`
자세한 정책과 회귀 테스트는 [make dev Vite HMR Policy](docs/make-dev-vite-hmr-policy.md)를 확인하세요. 정책 회귀는 `test/frontend_dev_bind_mount_policy_test.sh`에서 검사합니다.
로컬 Playwright E2E도 기본적으로 Vite dev server를 봅니다. Gitea Actions 같은 CI에서는 `CI=true`로 production bundle을 `vite preview`로 검증합니다. 로컬에서 production bundle을 명시적으로 검증하려면 `PLAYWRIGHT_USE_PREVIEW=true`를 사용하세요. 이 정책은 `test/playwright_frontend_runtime_policy_test.sh`에서 검사합니다.
## 🏗 아키텍처 (Architecture)
@@ -380,18 +394,18 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비
### SSOT 및 Redis Cache 전략
Baron SSO의 인증, 권한, OAuth/OIDC 원장은 Ory Stack입니다. Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane이며, Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 데이터만 read model로 보관합니다. Redis는 원장 데이터와 허용된 read model의 성능 cache/mirror로만 사용합니다.
Baron SSO의 인증, 권한, OAuth/OIDC 원장은 Ory Stack입니다. Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane입니다. 사용자 identity/profile/소속/조직도 노출 데이터에 대해 Backend DB `users`를 원장 또는 read model로 사용하지 않습니다. Redis는 Ory 원장 데이터의 성능 cache/mirror로만 사용합니다.
Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직접 소비하지 않습니다. Backend가 Redis와 허용된 read model을 조합해 cursor 기반 API adminfront, orgfront, userfront, 외부 API에 제공합니다.
Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직접 소비하지 않습니다. Backend가 Redis mirror 또는 Ory Admin API fallback을 기준으로 cursor 기반 API adminfront, orgfront, userfront, 외부 API에 제공합니다.
#### 데이터별 원본 위치
| 데이터 | SSOT | 보조 저장소/캐시 | 비고 |
| --- | --- | --- | --- |
| Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror, PostgreSQL `users.id` 참조 | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. |
| 로그인 식별자 | Ory Kratos traits | Redis identity mirror, `user_login_ids` read model | Kratos가 인증 식별자의 원장이고 Backend DB는 중복/정책 검증용 read model입니다. |
| 사용자 이름, 이메일, 전화번호, role 기본값 | Ory Kratos traits | Redis identity mirror, PostgreSQL `users` read model | 인증/profile 계산에 필요한 최소 identity 값 Kratos 유지합니다. |
| Baron 사용자 운영 상태, soft delete, 운영 메타데이터 | Backend read model | Redis 조합 응답 cache | Ory에 같은 의미로 저장되거나 조회되지 않는 Baron 운영 데이터입니다. Kratos identity 삭제와 혼동하지 않습니다. |
| Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. |
| 로그인 식별자 | Ory Kratos traits | Redis identity mirror/index | Kratos가 인증 식별자의 원장입니다. |
| 사용자 이름, 이메일, 전화번호, role 기본값 | Ory Kratos traits | Redis identity mirror | 인증/profile 계산에 필요한 identity 값 Kratos 기준으로 유지합니다. |
| Baron 사용자 운영 상태, soft delete, 운영 메타데이터 | Ory Kratos traits/state 또는 별도 명시 원장 | Redis mirror/cache | Backend DB `users`를 사용자 read model로 사용하지 않습니다. |
| 테넌트 tree, slug, 조직/부서/직무/직책 | Ory Keto relation tuple, Backend read model | Redis/API response cache 가능 | 권한/관계 판단은 Keto가 원장입니다. Ory가 보관하거나 조회할 수 없는 조직 표시/검색 데이터만 Backend read model에 둡니다. |
| 권한/관계 | Ory Keto relation tuple | PostgreSQL outbox/status | Backend를 통해 relation command를 보내고 처리 상태를 추적합니다. |
| OAuth2/OIDC client, consent, token state | Ory Hydra | PostgreSQL `client_consents`, audit/read model | Hydra가 프로토콜 원장이며 로컬 테이블은 운영 조회/감사용입니다. |
@@ -405,10 +419,10 @@ Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직
#### SSOT 보장 원칙
1. Kratos/Hydra/Keto/WORKS로 향하는 쓰기 command는 Backend를 통과합니다.
2. Backend는 Ory write 성공 후 원장 ID를 기준으로 Ory를 재조회하고, Redis mirror를 갱신하거나 stale로 표시합니다. Backend DB 갱신은 Ory에 저장되지 않거나 조회가 불가능한 read model에 한정합니다.
2. Backend는 Ory write 성공 후 원장 ID를 기준으로 Ory를 재조회하고, Redis mirror를 갱신하거나 stale로 표시합니다. 사용자 identity/profile/소속 데이터는 Backend DB `users` read model로 갱신하지 않습니다.
3. write-through 갱신 실패 시 원장 write를 되돌린 것으로 간주하지 않습니다. 대신 mirror/cache 상태를 `stale` 또는 `failed`로 표시하고 drift report와 refresh 대상으로 둡니다.
4. Kratos Admin API 또는 Kratos DB를 Backend 밖에서 직접 수정하는 경로는 운영 정책상 금지합니다. 정비/DR처럼 예외가 필요한 경우에는 Redis mirror를 stale로 표시하고, full refresh와 drift report를 완료하기 전까지 cache 결과를 신뢰하지 않습니다.
5. Backend read model이나 Redis cache는 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자를 삭제/숨김 처리하지 않습니다.
5. Backend DB `users`나 Redis cache는 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자 데이터를 근거로 정상 목록을 만들지 않습니다.
6. frontend/API 대량 조회는 Backend가 제공하는 cursor 기반을 원칙으로 합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 사용자 수가 늘면 partial data를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다.
7. Redis cache miss가 발생한 단건 조회는 가능한 경우 SSOT로 fallback하고, fallback 성공 시 Redis를 갱신합니다. 목록 조회는 mirror 상태가 `ready`가 아니면 화면/API에 경고 상태를 함께 전달해야 합니다.
+12 -1
View File
@@ -1,4 +1,4 @@
FROM node:lts AS build
FROM node:lts AS deps
WORKDIR /workspace
@@ -22,6 +22,17 @@ ENV ORGFRONT_URL=$ORGFRONT_URL
RUN pnpm install --frozen-lockfile --ignore-scripts
FROM deps AS dev
WORKDIR /workspace/adminfront
ENV NODE_ENV=development
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
FROM deps AS build
WORKDIR /workspace/adminfront
RUN npm run build
@@ -0,0 +1,134 @@
{
"metric": "tenant-profile-local-performance",
"tenantId": "56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"actualApiBaseUrl": "http://localhost:5173/api",
"measuredAt": "2026-06-16T23:45:00.441Z",
"browser": "chromium",
"samples": [
{
"sample": 1,
"configFieldsVisibleMs": 424,
"networkIdleMs": 862,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 134
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 184
}
]
},
{
"sample": 2,
"configFieldsVisibleMs": 376,
"networkIdleMs": 751,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 20
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 133
}
]
},
{
"sample": 3,
"configFieldsVisibleMs": 400,
"networkIdleMs": 797,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 21
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 156
}
]
},
{
"sample": 4,
"configFieldsVisibleMs": 431,
"networkIdleMs": 843,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 25
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 178
}
]
},
{
"sample": 5,
"configFieldsVisibleMs": 380,
"networkIdleMs": 758,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 24
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 129
}
]
}
],
"summary": {
"configFieldsVisibleMs": {
"min": 376,
"max": 431,
"p50": 400,
"p95": 431
},
"networkIdleMs": {
"min": 751,
"max": 862,
"p50": 797,
"p95": 862
}
},
"screenshotPath": "/home/lectom/repos/baron-sso/adminfront/e2e-evidence/tenant-profile-performance-local.png"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+3 -1
View File
@@ -14,6 +14,8 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI && !process.env.PORT;
const usePreviewServer =
process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true";
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
/**
@@ -84,7 +86,7 @@ export default defineConfig({
webServer: process.env.BASE_URL
? undefined
: {
command: process.env.CI
command: usePreviewServer
? `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`
: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,
+4 -1
View File
@@ -10,4 +10,7 @@ b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-s
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,,
4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no
e41adf79-3d15-4807-8303-afbdb0f2bab7,SW_uploader,USER_GROUP,hanmac-family,sw-uploader,소프트웨어 배포 권한 그룹,,private,,no
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,,
ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916,일반회사,COMPANY_GROUP,,commercial,외부 기업회원 루트 테넌트,,,,
d19c10f0-0224-4bbb-bf3e-ce579c5338ea,공공기관,COMPANY_GROUP,,public-org,공공기관 기본 루트 테넌트,,,,
78accec5-8eba-4324-b8f1-10ab360011fe,교육/학생,COMPANY_GROUP,,edu,교육기관 및 학생 기본 루트 테넌트,,,,
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,개인사용자,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,,
1 id name type parent_tenant_slug slug memo email_domain visibility org_unit_type worksmobile_sync
10 e57cb22c-383e-4489-8c2f-0c5431917e86 (주)피티씨 COMPANY baron-group ptc pre-cast.co.kr
11 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7 MH_manager USER_GROUP hanmac-family mhd 맨아워 대시보드 권한 보유자그룹 private no
12 e41adf79-3d15-4807-8303-afbdb0f2bab7 SW_uploader USER_GROUP hanmac-family sw-uploader 소프트웨어 배포 권한 그룹 private no
13 9607eb7b-04d2-42ab-80fe-780fe21c7e8f ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916 Personal 일반회사 PERSONAL COMPANY_GROUP personal commercial 개인 사용자 기본 루트 테넌트 외부 기업회원 루트 테넌트
14 d19c10f0-0224-4bbb-bf3e-ce579c5338ea 공공기관 COMPANY_GROUP public-org 공공기관 기본 루트 테넌트
15 78accec5-8eba-4324-b8f1-10ab360011fe 교육/학생 COMPANY_GROUP edu 교육기관 및 학생 기본 루트 테넌트
16 9607eb7b-04d2-42ab-80fe-780fe21c7e8f 개인사용자 PERSONAL personal 개인 사용자 기본 루트 테넌트
+42 -2
View File
@@ -28,16 +28,33 @@ describe("admin routes", () => {
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
});
it("routes global custom claim settings before user detail id matching", () => {
it("routes global custom claim settings before user detail id matching", async () => {
const matches = matchRoutes(adminRoutes, "/users/custom-claims");
const leafRoute = matches?.at(-1)?.route;
expect(leafRoute?.path).toBe("users/custom-claims");
expect(getRouteElementName(leafRoute?.element)).toBe(
expect(await getRouteComponentName(leafRoute)).toBe(
"GlobalCustomClaimsPage",
);
});
it("code-splits tenant detail profile routes away from the initial admin shell", () => {
const matches = matchRoutes(
adminRoutes,
"/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
);
const detailRoute = matches?.find(
(match) => match.route.path === "tenants/:tenantId",
)?.route;
const profileRoute = matches?.at(-1)?.route;
expect(detailRoute?.element).toBeUndefined();
expect(typeof detailRoute?.lazy).toBe("function");
expect(profileRoute?.index).toBe(true);
expect(profileRoute?.element).toBeUndefined();
expect(typeof profileRoute?.lazy).toBe("function");
});
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
const rootRoute = adminRoutes.find((route) => route.path === "/");
const protectedShellRoute = rootRoute?.children?.[0];
@@ -48,6 +65,29 @@ describe("admin routes", () => {
});
});
async function getRouteComponentName(route: unknown) {
if (
typeof route === "object" &&
route !== null &&
"lazy" in route &&
typeof route.lazy === "function"
) {
const lazyRoute = await route.lazy();
if ("Component" in lazyRoute && typeof lazyRoute.Component === "function") {
return lazyRoute.Component.name;
}
if ("element" in lazyRoute) {
return getRouteElementName(lazyRoute.element);
}
}
if (typeof route === "object" && route !== null && "element" in route) {
return getRouteElementName(route.element);
}
return undefined;
}
function getRouteElementName(element: unknown) {
if (
typeof element === "object" &&
+149 -43
View File
@@ -1,32 +1,33 @@
import type { ComponentType } from "react";
import type { RouteObject } from "react-router-dom";
import { createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
import OrySSOTPage from "../features/ory-ssot/OrySSOTPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import { TenantFineGrainedPermissionsPage } from "../features/tenants/routes/TenantFineGrainedPermissionsPage";
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
import { ADMIN_AUTH_CALLBACK_PATH } from "../lib/authConfig";
type RouteModule = {
default: ComponentType;
};
function lazyDefault(loader: () => Promise<RouteModule>) {
return async () => {
const module = await loader();
return { Component: module.default };
};
}
function lazyNamed<TModule, TKey extends keyof TModule>(
loader: () => Promise<TModule>,
key: TKey,
) {
return async () => {
const module = await loader();
return { Component: module[key] as ComponentType };
};
}
export const adminRoutes: RouteObject[] = [
{
path: "/login",
@@ -43,42 +44,147 @@ export const adminRoutes: RouteObject[] = [
{
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/custom-claims", element: <GlobalCustomClaimsPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
{
index: true,
lazy: lazyDefault(
() => import("../features/overview/GlobalOverviewPage"),
),
},
{
path: "audit-logs",
lazy: lazyDefault(() => import("../features/audit/AuditLogsPage")),
},
{
path: "auth",
lazy: lazyDefault(() => import("../features/auth/AuthPage")),
},
{
path: "users",
lazy: lazyDefault(() => import("../features/users/UserListPage")),
},
{
path: "users/custom-claims",
lazy: lazyDefault(
() => import("../features/users/GlobalCustomClaimsPage"),
),
},
{
path: "users/new",
lazy: lazyDefault(() => import("../features/users/UserCreatePage")),
},
{
path: "users/:id",
lazy: lazyDefault(() => import("../features/users/UserDetailPage")),
},
{
path: "tenants",
lazy: lazyDefault(
() => import("../features/tenants/routes/TenantListPage"),
),
},
{
path: "tenants/new",
lazy: lazyDefault(
() => import("../features/tenants/routes/TenantCreatePage"),
),
},
{
path: "worksmobile",
lazy: lazyNamed(
() => import("../features/tenants/routes/TenantWorksmobilePage"),
"TenantWorksmobilePage",
),
},
{
path: "permissions-direct",
element: <TenantFineGrainedPermissionsPage />,
lazy: lazyNamed(
() =>
import(
"../features/tenants/routes/TenantFineGrainedPermissionsPage"
),
"TenantFineGrainedPermissionsPage",
),
},
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
lazy: lazyDefault(
() => import("../features/tenants/routes/TenantDetailPage"),
),
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{
index: true,
lazy: lazyNamed(
() => import("../features/tenants/routes/TenantProfilePage"),
"TenantProfilePage",
),
},
{
path: "permissions",
lazy: lazyNamed(
() =>
import(
"../features/tenants/routes/TenantAdminsAndOwnersTab"
),
"TenantAdminsAndOwnersTab",
),
},
{
path: "organization",
lazy: lazyDefault(
() =>
import(
"../features/user-groups/routes/TenantUserGroupsTab"
),
),
},
{
path: "schema",
lazy: lazyNamed(
() => import("../features/tenants/routes/TenantSchemaPage"),
"TenantSchemaPage",
),
},
{
path: "relations",
element: <TenantFineGrainedPermissionsTab />,
lazy: lazyNamed(
() =>
import(
"../features/tenants/routes/TenantFineGrainedPermissionsTab"
),
"TenantFineGrainedPermissionsTab",
),
},
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
lazy: lazyDefault(
() =>
import("../features/user-groups/routes/TenantUserGroupsTab"),
),
},
{
path: "api-keys",
lazy: lazyDefault(
() => import("../features/api-keys/ApiKeyListPage"),
),
},
{
path: "api-keys/new",
lazy: lazyDefault(
() => import("../features/api-keys/ApiKeyCreatePage"),
),
},
{
path: "system/ory-ssot",
lazy: lazyDefault(() => import("../features/ory-ssot/OrySSOTPage")),
},
{
path: "system/data-integrity",
lazy: lazyDefault(
() => import("../features/integrity/DataIntegrityPage"),
),
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/ory-ssot", element: <OrySSOTPage /> },
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
],
},
],
File diff suppressed because it is too large Load Diff
@@ -104,6 +104,10 @@ import {
type TenantImportPreviewRow,
type TenantImportResolution,
} from "../utils/tenantCsvImport";
import {
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
} from "../utils/orgConfig";
import {
filterTenantsByScope,
filterTenantViewRowsBySearch,
@@ -119,14 +123,30 @@ const tenantCSVTemplate =
const tenantPageSize = 500;
const _tenantVirtualizationThreshold = 250;
const _tenantEstimatedRowHeight = 73;
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
const tenantTableHeadClassName =
"h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap";
const tenantTableHeadInteractiveClassName = `${tenantTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`;
const tenantTableHeadContentClassName = "flex h-full items-center gap-1";
const _tenantLoadAheadPx = 360;
const _tenantLoadAheadRows = 30;
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
const backendTenantSortKeys = new Set<TenantSortKey>([
"createdAt",
"id",
"name",
"slug",
"status",
"type",
"updatedAt",
]);
const bulkTenantTypeOptions = [
{ value: "COMPANY", label: "COMPANY (일반 기업)" },
{ value: "COMPANY_GROUP", label: "COMPANY_GROUP (그룹사/지주사)" },
{ value: "ORGANIZATION", label: "ORGANIZATION (정규 조직)" },
{ value: "USER_GROUP", label: "USER_GROUP (내부 부서/팀)" },
{ value: "PERSONAL", label: "PERSONAL (개인 워크스페이스)" },
] as const;
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
@@ -370,6 +390,10 @@ function TenantListPage() {
const [search, setSearch] = React.useState("");
const debouncedSearch = React.useDeferredValue(search.trim());
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
const [selectedBulkType, setSelectedBulkType] = React.useState("");
const [selectedBulkVisibility, setSelectedBulkVisibility] = React.useState<
TenantVisibility | ""
>("");
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
const { data: profile } = useQuery({
@@ -380,9 +404,20 @@ function TenantListPage() {
const isWritable =
profileRole === "super_admin" ||
!!profile?.systemPermissions?.manage_tenants;
const backendSortKey =
sortConfig && backendTenantSortKeys.has(sortConfig.key)
? sortConfig.key
: undefined;
const query = useInfiniteQuery({
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
queryKey: [
"tenants",
"lazy",
debouncedSearch,
scopeTenantId,
backendSortKey,
sortConfig?.direction,
],
queryFn: ({ pageParam }) =>
fetchTenants(
tenantPageSize,
@@ -390,12 +425,19 @@ function TenantListPage() {
scopeTenantId || undefined,
pageParam ? (pageParam as string) : undefined,
debouncedSearch,
backendSortKey,
sortConfig?.direction,
),
initialPageParam: "",
getNextPageParam: (lastPage) =>
lastPage.nextCursor || lastPage.next_cursor || undefined,
});
const rawTenants = React.useMemo(
() => query.data?.pages.flatMap((page) => page.items) ?? [],
[query.data?.pages],
);
const deleteBulkMutation = useMutation({
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
onSuccess: () => {
@@ -404,21 +446,37 @@ function TenantListPage() {
},
});
const bulkUpdateStatusMutation = useMutation({
const bulkUpdateTenantsMutation = useMutation({
mutationFn: async ({
tenantIds,
status,
type,
visibility,
}: {
tenantIds: string[];
status: string;
status?: string;
type?: string;
visibility?: TenantVisibility;
}) => {
// Execute sequential updates to avoid rate limits or partial failures
await Promise.all(tenantIds.map((id) => updateTenant(id, { status })));
await Promise.all(
tenantIds.map((id) => {
const source = rawTenants.find((tenant) => tenant.id === id);
return updateTenant(id, {
...(status ? { status } : {}),
...(type ? { type } : {}),
...(visibility
? { config: { ...(source?.config ?? {}), visibility } }
: {}),
});
}),
);
},
onSuccess: () => {
query.refetch();
setSelectedIds([]);
setSelectedBulkStatus("");
setSelectedBulkType("");
setSelectedBulkVisibility("");
toast.success(
t(
"msg.admin.tenants.bulk.update_success",
@@ -437,10 +495,19 @@ function TenantListPage() {
});
const handleApplyBulkStatus = () => {
if (selectedIds.length === 0 || !selectedBulkStatus) return;
bulkUpdateStatusMutation.mutate({
if (
selectedIds.length === 0 ||
(!selectedBulkStatus && !selectedBulkType && !selectedBulkVisibility)
) {
return;
}
bulkUpdateTenantsMutation.mutate({
tenantIds: selectedIds,
status: selectedBulkStatus,
...(selectedBulkStatus ? { status: selectedBulkStatus } : {}),
...(selectedBulkType ? { type: selectedBulkType } : {}),
...(selectedBulkVisibility
? { visibility: selectedBulkVisibility }
: {}),
});
};
@@ -491,11 +558,6 @@ function TenantListPage() {
},
});
const rawTenants = React.useMemo(
() => query.data?.pages.flatMap((page) => page.items) ?? [],
[query.data?.pages],
);
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
@@ -1067,15 +1129,66 @@ function TenantListPage() {
</SelectItem>
</SelectContent>
</Select>
<Select value={selectedBulkType} onValueChange={setSelectedBulkType}>
<SelectTrigger
className="h-8 w-[180px] bg-transparent border-background/20 text-background text-xs"
data-testid="tenant-bulk-type-select"
>
<SelectValue
placeholder={t(
"ui.admin.tenants.bulk.type_placeholder",
"유형 선택",
)}
/>
</SelectTrigger>
<SelectContent>
{bulkTenantTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(
`domain.tenant_type.${option.value.toLowerCase()}`,
option.label,
)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedBulkVisibility}
onValueChange={(value) =>
setSelectedBulkVisibility(value as TenantVisibility)
}
>
<SelectTrigger
className="h-8 w-[130px] bg-transparent border-background/20 text-background text-xs"
data-testid="tenant-bulk-visibility-select"
>
<SelectValue
placeholder={t(
"ui.admin.tenants.bulk.visibility_placeholder",
"공개 범위",
)}
/>
</SelectTrigger>
<SelectContent>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={handleApplyBulkStatus}
disabled={
!selectedBulkStatus || bulkUpdateStatusMutation.isPending
(!selectedBulkStatus &&
!selectedBulkType &&
!selectedBulkVisibility) ||
bulkUpdateTenantsMutation.isPending
}
data-testid="tenant-bulk-apply-status-btn"
data-testid="tenant-bulk-apply-btn"
>
{t("ui.common.apply", "적용")}
</Button>
@@ -0,0 +1,81 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../../test/i18nMock";
import { TenantProfilePage } from "./TenantProfilePage";
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
const fetchTenantMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
vi.mock("../../../lib/adminApi", () => ({
approveTenant: vi.fn(),
deleteTenant: vi.fn(),
fetchAllTenants: fetchAllTenantsMock,
fetchMe: vi.fn(async () => ({
id: "admin-1",
role: "super_admin",
})),
fetchTenant: fetchTenantMock,
updateTenant: vi.fn(),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/tenant-leaf"]}>
{ui}
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantProfilePage initial profile loading", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchTenantMock.mockResolvedValue({
id: "tenant-leaf",
type: "USER_GROUP",
parentId: "tenant-company",
name: "기술기획",
slug: "tech-planning",
description: "",
status: "active",
domains: [],
memberCount: 0,
config: {
orgUnitType: "팀",
visibility: "internal",
worksmobileExcluded: true,
},
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
});
});
it("renders tenant config fields from the tenant response before the full tenant list resolves", async () => {
fetchAllTenantsMock.mockReturnValue(new Promise(() => undefined));
renderWithProviders(
<Routes>
<Route path="/tenants/:tenantId" element={<TenantProfilePage />} />
</Routes>,
);
expect(await screen.findByDisplayValue("기술기획")).toBeInTheDocument();
expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded");
expect(fetchAllTenantsMock).not.toHaveBeenCalled();
});
});
@@ -57,11 +57,6 @@ export function TenantProfilePage() {
const isWritable = hasPermission("manage_profile") || hasPermission("manage");
const canView = hasPermission("view_profile") || hasPermission("view");
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
});
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
@@ -94,6 +89,16 @@ export function TenantProfilePage() {
}
}, [tenantQuery.data]);
const hasPersistedOrgConfig =
tenantQuery.data?.slug?.toLowerCase() !== "hanmac-family" &&
(typeof tenantQuery.data?.config?.orgUnitType === "string" ||
typeof tenantQuery.data?.config?.visibility === "string" ||
typeof tenantQuery.data?.config?.worksmobileExcluded !== "undefined");
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
enabled: !!tenantQuery.data && !hasPersistedOrgConfig,
});
const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data
? {
@@ -103,7 +108,8 @@ export function TenantProfilePage() {
}
: undefined;
const canEditOrgConfig = orgConfigCandidate
? shouldAllowHanmacOrgConfig(orgConfigCandidate, [
? hasPersistedOrgConfig ||
shouldAllowHanmacOrgConfig(orgConfigCandidate, [
...allTenants,
orgConfigCandidate,
])
@@ -361,7 +367,10 @@ export function TenantProfilePage() {
data-testid="tenant-org-unit-type-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
<Label
htmlFor="tenant-org-unit-type"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
@@ -385,7 +394,10 @@ export function TenantProfilePage() {
</select>
</div>
<div data-testid="tenant-visibility-slot" className="space-y-1">
<Label className="text-sm font-semibold">
<Label
htmlFor="tenant-visibility"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
@@ -411,7 +423,10 @@ export function TenantProfilePage() {
data-testid="tenant-worksmobile-excluded-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
<Label
htmlFor="worksmobileExcluded"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.worksmobile_sync",
"WORKS 연동",
@@ -624,6 +624,52 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toEqual(["조직: Baron 소속 정보를 WORKS에 반영해야 합니다."]);
});
it("formats grade update reasons with before and after values", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
externalKey: "user-1",
baronName: "신현우",
worksmobileName: "신현우",
baronGrade: "책임",
worksmobileLevelName: "선임",
updateReasons: ["grade"],
}),
).toEqual(["직급: 선임 -> 책임"]);
});
it("formats grade update reasons with matched WORKS membership", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
externalKey: "user-1",
baronName: "연구원",
worksmobileName: "연구원",
baronGrade: "책임연구원",
worksmobileLevelName: "",
updateReasons: ["grade"],
userMemberships: [
{
baronOrgId: "1d74bebb-c5a1-49d4-bec4-90f0c89ad21f",
baronOrgSlug: "hmeg",
baronOrgName: "HmEG",
baronGrade: "책임연구원",
worksmobileOrgId: "works-hmeg",
worksmobileOrgName: "WORKS HmEG",
worksmobileDomainName: "baroncs.co.kr",
gradeNeedsUpdate: true,
},
],
}),
).toEqual([
"직급: 없음 -> 책임연구원 (Baron HmEG / WORKS WORKS HmEG)",
]);
});
it("does not format phone update details for spaced Korean country code formatting only", () => {
expect(
formatWorksmobileUpdateDetails({
@@ -68,6 +68,7 @@ import {
formatWorksmobilePersonName,
formatWorksmobileSelectionFailureDescription,
formatWorksmobileUpdateDetails,
formatWorksmobileUserMembershipDetails,
getDefaultGroupComparisonFilters,
getDefaultUserComparisonFilters,
getDefaultWorksmobileComparisonColumns,
@@ -813,7 +814,7 @@ const worksmobileComparisonColumnOptions: Array<{
{ key: "externalKey", label: "external_key" },
{ key: "worksmobileDomain", label: "WORKS 도메인" },
{ key: "worksmobile", label: "WORKS" },
{ key: "worksmobileOrg", label: "상위 Works 조직" },
{ key: "worksmobileOrg", label: "WORKS 조직 매칭" },
{ key: "manage", label: "관리" },
];
@@ -832,7 +833,7 @@ const worksmobileComparisonColumnWidths: Record<
worksmobileDomain: 160,
worksmobileId: 176,
worksmobile: 220,
worksmobileOrg: 260,
worksmobileOrg: 320,
manage: 112,
};
const worksmobileComparisonTableHeadClassName =
@@ -1539,7 +1540,7 @@ function ComparisonTable({
<div
className={worksmobileComparisonTableHeadContentClassName}
>
Works
WORKS
</div>
</TableHead>
)}
@@ -1724,33 +1725,17 @@ function ComparisonTable({
)}
{isColumnVisible("worksmobileOrg") && (
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? getWorksmobileParentName(row)
: row.worksmobilePrimaryOrgName
}
email={
row.resourceType === "GROUP"
? getWorksmobileParentEmail(row)
: undefined
}
id={
row.resourceType === "GROUP"
? row.worksmobileParentId
: row.worksmobilePrimaryOrgId
}
details={
row.resourceType === "GROUP"
? formatWorksmobileParentOrgDetails(row)
: formatWorksmobileOrgDetails(row)
}
missingLabel={
row.resourceType === "GROUP"
? "상위 Works 조직 정보 없음"
: undefined
}
/>
{row.resourceType === "USER" ? (
<ComparisonUserMembershipCell row={row} />
) : (
<ComparisonOrgCell
name={getWorksmobileParentName(row)}
email={getWorksmobileParentEmail(row)}
id={row.worksmobileParentId}
details={formatWorksmobileParentOrgDetails(row)}
missingLabel="상위 Works 조직 정보 없음"
/>
)}
</TableCell>
)}
{showManageColumn && isColumnVisible("manage") && (
@@ -1893,6 +1878,33 @@ function formatWorksmobileParentOrgDetails(row: WorksmobileComparisonItem) {
return details;
}
function ComparisonUserMembershipCell({
row,
}: {
row: WorksmobileComparisonItem;
}) {
const membershipDetails = formatWorksmobileUserMembershipDetails(row);
if (membershipDetails.length > 0) {
return (
<div className="space-y-1">
{membershipDetails.map((detail) => (
<div key={detail} className="text-xs leading-relaxed">
{detail}
</div>
))}
</div>
);
}
return (
<ComparisonOrgCell
name={row.worksmobilePrimaryOrgName}
id={row.worksmobilePrimaryOrgId}
details={formatWorksmobileOrgDetails(row)}
/>
);
}
function ComparisonOrgCell({
name,
email,
@@ -365,6 +365,32 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
return details;
}
export function formatWorksmobileUserMembershipDetails(
row: WorksmobileComparisonItem,
) {
return (row.userMemberships ?? []).map((membership) => {
const baronOrg =
membership.baronOrgName?.trim() ||
membership.baronOrgSlug?.trim() ||
membership.baronOrgId?.trim() ||
"Baron 조직";
const worksOrg =
membership.worksmobileOrgName?.trim() ||
membership.worksmobileOrgId?.trim() ||
"WORKS 조직 없음";
const details = [
membership.baronPrimary ? "기본" : "겸직",
`Baron ${baronOrg}`,
`WORKS ${worksOrg}`,
membership.worksmobileLevelName?.trim() ||
membership.worksmobileLevelId?.trim()
? `직급 ${membership.worksmobileLevelName?.trim() || membership.worksmobileLevelId?.trim()}`
: "",
].filter(Boolean);
return details.join(" / ");
});
}
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
return [`최근 실패: ${row.worksmobileLastError}`];
@@ -420,6 +446,20 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
);
}
const expectedGrade = row.baronGrade?.trim() ?? "";
const actualGrade =
row.worksmobileLevelName?.trim() ?? row.worksmobileLevelId?.trim() ?? "";
if (
row.updateReasons?.includes("grade") &&
(expectedGrade || actualGrade) &&
expectedGrade !== actualGrade
) {
const membershipContext = formatWorksmobileGradeMembershipContext(row);
addDetail(
"grade",
`직급: ${actualGrade || "없음"} -> ${expectedGrade || "없음"}${membershipContext}`,
);
}
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
return details;
}
@@ -484,6 +524,17 @@ function formatWorksmobileUpdateReasonFallback(
return "전화번호: Baron 전화번호를 WORKS에 반영해야 합니다.";
case "employee_number":
return "사번: Baron 사번을 WORKS에 반영해야 합니다.";
case "grade": {
const expectedGrade = row.baronGrade?.trim() ?? "";
const actualGrade =
row.worksmobileLevelName?.trim() ??
row.worksmobileLevelId?.trim() ??
"";
if (expectedGrade || actualGrade) {
return `직급: ${actualGrade || "없음"} -> ${expectedGrade || "없음"}${formatWorksmobileGradeMembershipContext(row)}`;
}
return "직급: Baron 직급 정보를 WORKS에 반영해야 합니다.";
}
case "organization":
return row.resourceType === "GROUP"
? "조직: Baron 조직 정보를 WORKS에 반영해야 합니다."
@@ -495,6 +546,32 @@ function formatWorksmobileUpdateReasonFallback(
}
}
function formatWorksmobileGradeMembershipContext(
row: WorksmobileComparisonItem,
) {
const membership =
row.userMemberships?.find((item) => item.gradeNeedsUpdate) ??
row.userMemberships?.find(
(item) =>
item.baronGrade?.trim() &&
item.baronGrade?.trim() === row.baronGrade?.trim(),
);
if (!membership) {
return "";
}
const baronOrg =
membership.baronOrgName?.trim() ||
membership.baronOrgSlug?.trim() ||
membership.baronOrgId?.trim();
const worksOrg =
membership.worksmobileOrgName?.trim() ||
membership.worksmobileOrgId?.trim();
if (!baronOrg && !worksOrg) {
return "";
}
return ` (Baron ${baronOrg || "없음"} / WORKS ${worksOrg || "없음"})`;
}
function normalizeWorksmobilePhoneForCompare(value: string) {
const trimmed = value.trim();
if (!trimmed) {
@@ -320,6 +320,36 @@ describe("UserDetailPage Worksmobile employee number", () => {
);
});
it("does not reveal the manually entered password after a successful reset", async () => {
renderUserDetailPage();
fireEvent.click(await screen.findByRole("tab", { name: "보안 & 활동" }));
fireEvent.click(screen.getByRole("button", { name: "초기화 도구" }));
fireEvent.click(screen.getByRole("tab", { name: "직접 입력" }));
const passwordInputs = document.querySelectorAll('input[type="password"]');
expect(passwordInputs).toHaveLength(2);
fireEvent.change(passwordInputs[0], {
target: { value: "ManualPass123!" },
});
fireEvent.change(passwordInputs[1], {
target: { value: "ManualPass123!" },
});
fireEvent.click(screen.getByRole("button", { name: "재설정 완료" }));
await waitFor(() =>
expect(updateUserMock).toHaveBeenCalledWith("user-1", {
password: "ManualPass123!",
}),
);
expect(screen.queryByText("ManualPass123!")).not.toBeInTheDocument();
expect(
document.querySelectorAll('input[value="ManualPass123!"]'),
).toHaveLength(0);
});
it("preserves per-user global custom claim permissions instead of overwriting them from definitions", async () => {
fetchUserMock.mockResolvedValueOnce({
id: "user-1",
@@ -394,4 +424,97 @@ describe("UserDetailPage Worksmobile employee number", () => {
}),
);
});
it("defaults a Hanmac family member to the Hanmac family tenant tab and does not show the external company tab", async () => {
fetchAllTenantsMock.mockResolvedValue({
items: [
{
id: "hanmac-root-id",
type: "COMPANY_GROUP",
name: "한맥가족",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
{
id: "hanmac-team-id",
type: "USER_GROUP",
name: "한맥팀",
slug: "hanmac-team",
parentId: "hanmac-root-id",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
{
id: "commercial-root-id",
type: "COMPANY_GROUP",
name: "Commercial",
slug: "commercial",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
],
total: 3,
});
fetchUserMock.mockResolvedValue({
id: "user-1",
email: "user@example.com",
name: "사용자",
phone: "01012345678",
role: "user",
status: "active",
tenantSlug: "hanmac-team",
tenant: {
id: "hanmac-team-id",
type: "USER_GROUP",
name: "한맥팀",
slug: "hanmac-team",
parentId: "hanmac-root-id",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
joinedTenants: [],
metadata: {},
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
});
renderUserDetailPage();
await screen.findByRole("tab", { name: "한맥가족" });
const tenantTabs = screen
.getAllByRole("tab")
.filter((tab) =>
["한맥가족", "일반회사", "공공기관", "교육기관", "개인"].includes(
tab.textContent?.trim() ?? "",
),
);
expect(tenantTabs.map((tab) => tab.textContent?.trim())).toEqual([
"한맥가족",
"일반회사",
"공공기관",
"교육기관",
"개인",
]);
expect(screen.getByRole("tab", { name: "한맥가족" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(
screen.queryByRole("tab", { name: /외부 기업 회원/i }),
).not.toBeInTheDocument();
});
});
@@ -86,12 +86,14 @@ import {
import { generateSecurePassword } from "../../lib/utils";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
filterTenantsByMembershipRoot,
getTenantGradeOptions,
isHanmacFamilyTenant,
isHanmacFamilyUser,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
resolveUserMembershipTenantTab,
USER_MEMBERSHIP_TENANT_TABS,
type UserMembershipTenantTabId,
} from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields";
@@ -109,7 +111,7 @@ type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
sub_email?: string | string[];
};
};
type UserCategory = "hanmac" | "external" | "personal";
type UserCategory = UserMembershipTenantTabId;
type PasswordResetMode = "generated" | "manual";
type PickerTarget = { kind: "appointment"; index: number };
@@ -571,7 +573,7 @@ function UserDetailPage() {
string | null
>(null);
const [userCategory, setUserCategory] =
React.useState<UserCategory>("external");
React.useState<UserCategory>("hanmac-family");
const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[]
>([]);
@@ -692,9 +694,18 @@ function UserDetailPage() {
};
const resetMutation = useMutation({
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
onSuccess: (_, newPass) => {
setGeneratedPassword(newPass);
mutationFn: ({ password }: { password: string; mode: PasswordResetMode }) =>
updateUser(userId, { password }),
onSuccess: (_, { password, mode }) => {
if (mode === "manual") {
setGeneratedPassword(null);
setManualPassword("");
setManualPasswordConfirm("");
setIsManualPasswordVisible(false);
setIsPasswordResetOpen(false);
} else {
setGeneratedPassword(password);
}
setPasswordResetError(null);
toast.success(
t(
@@ -753,7 +764,7 @@ function UserDetailPage() {
newPass = generateSecurePassword();
}
resetMutation.mutate(newPass);
resetMutation.mutate({ password: newPass, mode: passwordResetMode });
};
const hanmacFamilyTenantId = React.useMemo(() => {
@@ -771,7 +782,8 @@ function UserDetailPage() {
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
{
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
tenantId:
userCategory === "hanmac-family" ? hanmacFamilyTenantId : undefined,
},
);
@@ -862,7 +874,7 @@ function UserDetailPage() {
const handleUserCategoryChange = (value: string) => {
const nextCategory = value as UserCategory;
setUserCategory(nextCategory);
if (nextCategory !== "hanmac") {
if (nextCategory !== "hanmac-family") {
setAdditionalAppointments([]);
}
};
@@ -930,21 +942,11 @@ function UserDetailPage() {
: [],
} as UserFormValues["metadata"],
});
const isUserHanmacFamily = isHanmacFamilyUser(
const resolvedUserCategory = resolveUserMembershipTenantTab(
user,
tenants,
hanmacFamilyTenantId,
);
const isPersonalUser =
user.tenantSlug === personalTenant.slug ||
user.tenant?.id === personalTenant.id ||
user.tenant?.slug === personalTenant.slug ||
metadata.personalTenantId === personalTenant.id;
const resolvedUserCategory = isPersonalUser
? "personal"
: isUserHanmacFamily
? "hanmac"
: "external";
).id;
const isUserHanmacFamily = resolvedUserCategory === "hanmac-family";
setUserCategory(resolvedUserCategory);
setGlobalCustomClaimRows(
createGlobalCustomClaimRows(metadata, globalCustomClaimDefinitions),
@@ -1009,7 +1011,6 @@ function UserDetailPage() {
}, [
globalCustomClaimDefinitions,
hanmacFamilyTenantId,
personalTenant,
tenants,
user,
reset,
@@ -1105,7 +1106,7 @@ function UserDetailPage() {
}
}
if (userCategory === "hanmac") {
if (userCategory === "hanmac-family") {
const appointments = additionalAppointments
.filter((appointment) => appointment.tenantId)
.map((appointment) => ({
@@ -1217,9 +1218,13 @@ function UserDetailPage() {
}, [tenants, user?.joinedTenants, user?.metadata, user?.tenant]);
const selectableRepresentativeTenants = React.useMemo(
() =>
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
[userAffiliatedTenants, hanmacFamilyTenantId],
userCategory === "hanmac-family" || userCategory === "personal"
? []
: filterTenantsByMembershipRoot(tenants, userCategory),
[tenants, userCategory],
);
const isRepresentativeTenantCategory =
userCategory !== "hanmac-family" && userCategory !== "personal";
if (isLoading) {
return (
@@ -1606,28 +1611,19 @@ function UserDetailPage() {
className="space-y-4 pt-6 border-t border-dashed"
>
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
<TabsTrigger
value="hanmac"
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
</TabsTrigger>
<TabsTrigger
value="external"
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
</TabsTrigger>
<TabsTrigger
value="personal"
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
</TabsTrigger>
{USER_MEMBERSHIP_TENANT_TABS.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{userCategory === "external" && (
{isRepresentativeTenantCategory && (
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-2">
<Label
@@ -1671,7 +1667,7 @@ function UserDetailPage() {
</div>
)}
{userCategory === "hanmac" && (
{userCategory === "hanmac-family" && (
<div className="space-y-4 rounded-md border p-4">
<div className="space-y-4">
<div className="space-y-3">
@@ -1893,7 +1889,7 @@ function UserDetailPage() {
</div>
)}
{userCategory === "external" && (
{isRepresentativeTenantCategory && (
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
<div className="space-y-2">
<Label
+4 -105
View File
@@ -97,7 +97,7 @@ import {
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
import { normalizeAdminRole } from "../../lib/roles";
import {
downloadUserTemplate,
UserBulkUploadModal,
@@ -121,7 +121,7 @@ type UserSortKey = string;
const USER_ROW_ESTIMATED_HEIGHT = 64;
const USER_ROW_OVERSCAN = 2;
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 220] as const;
const userMetadataColumnWidth = 160;
const userCreatedColumnWidth = 150;
type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>;
@@ -135,23 +135,6 @@ const userSortableTableHeadContentClassName = "h-full items-center";
const userTableStateCellClassName =
"flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground";
const bulkPermissionOptions = [
{
value: "super_admin",
labelKey: "ui.admin.role.super_admin",
fallback: "시스템 관리자",
},
{
value: "user",
labelKey: "ui.admin.role.user",
fallback: "일반 사용자",
},
] as const;
function assignableSystemRoleValue(role?: string | null) {
return isSuperAdminRole(role) ? "super_admin" : "user";
}
type RepresentativeTenantCandidate = {
id?: string;
slug?: string;
@@ -370,8 +353,6 @@ function UserListPage() {
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
UserStatusValue | ""
>("");
const [selectedBulkPermission, setSelectedBulkPermission] =
React.useState("");
const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null);
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
@@ -604,7 +585,7 @@ function UserListPage() {
]);
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
const tableColumnCount = 9 + visibleUserSchemaFields.length;
const tableColumnCount = 8 + visibleUserSchemaFields.length;
const requestSort = (key: UserSortKey) => {
setSortConfig((current) => toggleSort(current, key));
@@ -622,8 +603,6 @@ function UserListPage() {
};
const total = query.data?.pages[0]?.total ?? 0;
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
const toggleSelectAll = () => {
if (selectedUserIds.length === items.length) {
setSelectedUserIds([]);
@@ -673,7 +652,6 @@ function UserListPage() {
query.refetch();
setSelectedUserIds([]);
setSelectedBulkStatus("");
setSelectedBulkPermission("");
toast.success(
t(
"msg.admin.users.bulk.update_success",
@@ -691,14 +669,6 @@ function UserListPage() {
});
};
const _handleApplyBulkPermission = () => {
if (selectedUserIds.length === 0 || !selectedBulkPermission) return;
bulkUpdateMutation.mutate({
userIds: selectedUserIds,
role: selectedBulkPermission,
});
};
const handleBulkDelete = () => {
if (selectedUserIds.length === 0) return;
if (
@@ -1005,15 +975,6 @@ function UserListPage() {
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("role")}
>
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.role", "ROLE")}
{getSortIcon("role")}
</div>
</TableHead>
<TableHead
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("tenant_dept")}
@@ -1206,36 +1167,6 @@ function UserListPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
value={assignableSystemRoleValue(user.role)}
onValueChange={(value) =>
bulkUpdateMutation.mutate({
userIds: [user.id],
role: value,
})
}
disabled={
bulkUpdateMutation.isPending ||
!isSuperAdminRole(profile?.role) ||
user.id === profile?.id
}
>
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bulkPermissionOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{t(option.labelKey, option.fallback)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
@@ -1302,31 +1233,6 @@ function UserListPage() {
))}
</SelectContent>
</Select>
{canPromoteSuperAdmin && (
<Select
value={selectedBulkPermission}
onValueChange={setSelectedBulkPermission}
>
<SelectTrigger
className="h-8 w-[120px] bg-transparent border-background/20 text-background text-xs"
data-testid="bulk-permission-select"
>
<SelectValue
placeholder={t(
"ui.admin.users.bulk.permission_placeholder",
"권한 선택",
)}
/>
</SelectTrigger>
<SelectContent>
{bulkPermissionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button
variant="ghost"
size="sm"
@@ -1335,25 +1241,18 @@ function UserListPage() {
const payload: {
userIds: string[];
status?: UserStatusValue;
role?: string;
} = { userIds: selectedUserIds };
let hasChanges = false;
if (selectedBulkStatus) {
payload.status = selectedBulkStatus;
hasChanges = true;
}
if (selectedBulkPermission && canPromoteSuperAdmin) {
payload.role = selectedBulkPermission;
hasChanges = true;
}
if (hasChanges) {
bulkUpdateMutation.mutate(payload);
}
}}
disabled={
(!selectedBulkStatus && !selectedBulkPermission) ||
bulkUpdateMutation.isPending ||
!isWritable
!selectedBulkStatus || bulkUpdateMutation.isPending || !isWritable
}
data-testid="bulk-apply-btn"
>
@@ -4,11 +4,13 @@ import {
buildAuthenticatedOrgChartUrl,
buildAuthenticatedOrgChartUserMultiPickerUrl,
buildOrgChartTenantPickerUrl,
classifyTenantByMembershipRoot,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
parseOrgChartUserSelections,
USER_MEMBERSHIP_TENANT_TABS,
} from "./orgChartPicker";
describe("orgChartPicker", () => {
@@ -67,7 +69,18 @@ describe("orgChartPicker", () => {
"https://orgchart.example.com",
),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dmultiple%26select%3Duser%26width%3D720%26height%3D640%26includeInternal%3Dtrue%26includeDescendants%3Dtrue%26showDescendantToggle%3Dtrue",
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dmultiple%26select%3Duser%26width%3D720%26height%3D640%26includeInternal%3Dtrue%26includeDescendants%3Dtrue%26showDescendantToggle%3Dtrue%26rootTenantId%3Dall",
);
});
it("builds a scoped authenticated multi picker URL for recursive tenant user selection", () => {
expect(
buildAuthenticatedOrgChartUserMultiPickerUrl(
"https://orgchart.example.com",
{ tenantId: "tenant-a" },
),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dmultiple%26select%3Duser%26width%3D720%26height%3D640%26includeInternal%3Dtrue%26includeDescendants%3Dtrue%26showDescendantToggle%3Dtrue%26tenantId%3Dtenant-a",
);
});
@@ -135,8 +148,15 @@ describe("orgChartPicker", () => {
id: "user-1",
name: "홍길동",
email: "hong@example.com",
rootTenantName: "한맥가족",
leafTenantName: "기술기획",
},
{
type: "user",
id: "user-2",
name: "김영희",
tenantName: "디자인팀",
},
{ type: "user", id: "user-2", name: "김영희" },
{ type: "user", id: "", name: "잘못된 사용자" },
],
},
@@ -146,11 +166,15 @@ describe("orgChartPicker", () => {
id: "user-1",
name: "홍길동",
email: "hong@example.com",
rootTenantName: "한맥가족",
leafTenantName: "기술기획",
},
{
id: "user-2",
name: "김영희",
email: "",
rootTenantName: undefined,
leafTenantName: "디자인팀",
},
]);
});
@@ -366,11 +390,48 @@ describe("orgChartPicker", () => {
"차장",
"부장",
"이사",
"상무",
"전무",
"상무이사",
"전무이사",
"부사장",
"사장",
"회장",
]);
});
it("classifies tenants by the configured top-level tenant root UUIDs", () => {
const tenants = [
{
id: "hanmac-root-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "commercial-root-id",
slug: "commercial",
name: "Commercial",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "commercial-child-id",
slug: "external-company",
name: "외부기업",
type: "COMPANY",
parentId: "commercial-root-id",
},
];
expect(USER_MEMBERSHIP_TENANT_TABS.map((tab) => tab.id)).toEqual([
"hanmac-family",
"commercial",
"public-org",
"edu",
"personal",
]);
expect(classifyTenantByMembershipRoot(tenants[2], tenants)?.id).toBe(
"commercial",
);
});
});
+189 -5
View File
@@ -7,6 +7,8 @@ export type OrgChartUserSelection = {
id: string;
name: string;
email: string;
rootTenantName?: string;
leafTenantName?: string;
};
export type TenantFilterTarget = {
@@ -30,6 +32,20 @@ export type HanmacFamilyUserTarget = {
metadata?: Record<string, unknown>;
};
export type UserMembershipTenantTabId =
| "hanmac-family"
| "commercial"
| "public-org"
| "edu"
| "personal";
export type UserMembershipTenantTab = {
id: UserMembershipTenantTabId;
label: string;
rootSlug: string;
seedTenantId?: string;
};
type OrgChartPickerMessage = {
type?: unknown;
payload?: {
@@ -38,6 +54,9 @@ type OrgChartPickerMessage = {
id?: unknown;
name?: unknown;
email?: unknown;
rootTenantName?: unknown;
leafTenantName?: unknown;
tenantName?: unknown;
}>;
};
};
@@ -47,6 +66,10 @@ type OrgChartTenantPickerOptions = {
tenantId?: string;
};
type OrgChartUserMultiPickerOptions = {
tenantId?: string;
};
type OrgChartLoginOptions = {
includeInternal?: boolean;
returnTo?: string;
@@ -54,6 +77,36 @@ type OrgChartLoginOptions = {
const DEFAULT_ORGFRONT_BASE_URL = "http://localhost:5175";
export const USER_MEMBERSHIP_TENANT_TABS: UserMembershipTenantTab[] = [
{
id: "hanmac-family",
label: "한맥가족",
rootSlug: "hanmac-family",
seedTenantId: "038326b6-954a-48a7-a85f-efd83f62b82a",
},
{
id: "commercial",
label: "일반회사",
rootSlug: "commercial",
},
{
id: "public-org",
label: "공공기관",
rootSlug: "public-org",
},
{
id: "edu",
label: "교육기관",
rootSlug: "edu",
},
{
id: "personal",
label: "개인",
rootSlug: "personal",
seedTenantId: "9607eb7b-04d2-42ab-80fe-780fe21c7e8f",
},
];
export const GPDTDC_GRADE_OPTIONS = [
"연구원",
"선임",
@@ -70,8 +123,8 @@ export const HANMAC_FAMILY_GRADE_OPTIONS = [
"차장",
"부장",
"이사",
"상무",
"전무",
"상무이사",
"전무이사",
"부사장",
"사장",
"회장",
@@ -108,6 +161,118 @@ function resolveTenantTarget<T extends TenantFilterTarget>(
);
}
function resolveMembershipRoot<T extends TenantFilterTarget>(
tab: UserMembershipTenantTab,
tenants: T[],
) {
const rootSlug = tab.rootSlug.toLowerCase();
return (
tenants.find(
(tenant) => tab.seedTenantId && tenant.id === tab.seedTenantId,
) ??
tenants.find((tenant) => tenant.slug?.trim().toLowerCase() === rootSlug)
);
}
export function classifyTenantByMembershipRoot<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
const tenant = resolveTenantTarget(target, tenants);
if (!tenant?.id) return undefined;
const tenantById = new Map(
tenants
.filter((item) => item.id?.trim())
.map((item) => [item.id as string, item]),
);
return USER_MEMBERSHIP_TENANT_TABS.find((tab) => {
const root = resolveMembershipRoot(tab, tenants);
if (!root?.id) return false;
const resolvedTenant = tenantById.get(tenant.id ?? "") ?? tenant;
return isInTenantSubtree(resolvedTenant, root.id, tenantById);
});
}
export function filterTenantsByMembershipRoot<T extends TenantFilterTarget>(
tenants: T[],
tabId: UserMembershipTenantTabId,
) {
const tab = USER_MEMBERSHIP_TENANT_TABS.find((item) => item.id === tabId);
if (!tab) return [];
const root = resolveMembershipRoot(tab, tenants);
if (!root?.id) return [];
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
return tenants.filter(
(tenant) =>
!isSystemTenant(tenant) &&
isPublicRepresentativeTenant(tenant) &&
isInTenantSubtree(tenant, root.id as string, tenantById),
);
}
export function resolveUserMembershipTenantTab<T extends TenantFilterTarget>(
user: HanmacFamilyUserTarget,
tenants: T[],
) {
const metadataAppointments = Array.isArray(
user.metadata?.additionalAppointments,
)
? user.metadata.additionalAppointments
.map((appointment) => appointment as TenantFilterTarget)
.filter(
(appointment) =>
typeof appointment.tenantId === "string" ||
typeof appointment.id === "string" ||
typeof appointment.tenantSlug === "string" ||
typeof appointment.slug === "string",
)
.map((appointment) => ({
id: appointment.id ?? appointment.tenantId,
slug: appointment.slug ?? appointment.tenantSlug,
parentId: appointment.parentId,
type: appointment.type,
name: appointment.name ?? appointment.tenantName,
}))
: [];
const tenantBySlug = new Map(
tenants
.filter((tenant) => tenant.slug?.trim())
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
);
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
const candidates = [
user.tenant,
...(user.joinedTenants ?? []),
...metadataAppointments,
...metadataAppointments.map((appointment) =>
tenantById.get(appointment.id ?? ""),
),
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
];
return (
USER_MEMBERSHIP_TENANT_TABS.find((tab) =>
candidates.some(
(candidate) =>
classifyTenantByMembershipRoot(candidate, tenants)?.id === tab.id,
),
) ?? USER_MEMBERSHIP_TENANT_TABS[0]
);
}
function isGPDTDCTenant<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
@@ -326,7 +491,10 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
export function buildOrgChartUserMultiPickerUrl(baseUrl?: string) {
export function buildOrgChartUserMultiPickerUrl(
baseUrl?: string,
options: OrgChartUserMultiPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const params = new URLSearchParams({
mode: "multiple",
@@ -337,12 +505,18 @@ export function buildOrgChartUserMultiPickerUrl(baseUrl?: string) {
params.set("includeInternal", "true");
params.set("includeDescendants", "true");
params.set("showDescendantToggle", "true");
if (options.tenantId?.trim()) {
params.set("tenantId", options.tenantId.trim());
}
return `${normalizedBase}/embed/picker?${params.toString()}`;
}
export function buildAuthenticatedOrgChartUserMultiPickerUrl(baseUrl?: string) {
const pickerUrl = buildOrgChartUserMultiPickerUrl("");
export function buildAuthenticatedOrgChartUserMultiPickerUrl(
baseUrl?: string,
options: OrgChartUserMultiPickerOptions = {},
) {
const pickerUrl = buildOrgChartUserMultiPickerUrl("", options);
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
@@ -418,5 +592,15 @@ export function parseOrgChartUserSelections(
id: selection.id,
name: selection.name,
email: typeof selection.email === "string" ? selection.email : "",
rootTenantName:
typeof selection.rootTenantName === "string"
? selection.rootTenantName
: undefined,
leafTenantName:
typeof selection.leafTenantName === "string"
? selection.leafTenantName
: typeof selection.tenantName === "string"
? selection.tenantName
: undefined,
}));
}
+12 -1
View File
@@ -60,7 +60,15 @@ describe("adminApi endpoint contracts", () => {
period: "week",
tenantId: "tenant-1",
});
await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b");
await adminApi.fetchTenants(
25,
50,
"parent-1",
"cursor-b",
"saman",
"name",
"asc",
);
await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" });
await adminApi.fetchTenant("tenant-1");
await adminApi.fetchTenantAdmins("tenant-1");
@@ -97,6 +105,9 @@ describe("adminApi endpoint contracts", () => {
offset: 50,
parentId: "parent-1",
cursor: "cursor-b",
search: "saman",
sort: "name",
direction: "asc",
},
});
expect(fetchAllCursorPages).toHaveBeenCalledWith(
+23 -1
View File
@@ -310,11 +310,13 @@ export async function fetchTenants(
parentId?: string,
cursor?: string,
search?: string,
sort?: string,
direction?: "asc" | "desc",
) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset, parentId, cursor, search },
params: { limit, offset, parentId, cursor, search, sort, direction },
},
);
return data;
@@ -938,6 +940,7 @@ export type WorksmobileComparisonItem = {
baronEmail?: string;
baronPhone?: string;
baronEmployeeNumber?: string;
baronGrade?: string;
baronPrimaryOrgId?: string;
baronPrimaryOrgSlug?: string;
baronPrimaryOrgName?: string;
@@ -972,10 +975,29 @@ export type WorksmobileComparisonItem = {
worksmobileJobRetryCount?: number;
worksmobileLastError?: string;
worksmobileLastAttemptAt?: string;
userMemberships?: WorksmobileUserMembershipComparison[];
updateReasons?: string[];
status: string;
};
export type WorksmobileUserMembershipComparison = {
baronOrgId?: string;
baronOrgSlug?: string;
baronOrgName?: string;
baronGrade?: string;
baronPrimary?: boolean;
worksmobileDomainId?: number;
worksmobileDomainName?: string;
worksmobileOrgId?: string;
worksmobileOrgName?: string;
worksmobileLevelId?: string;
worksmobileLevelName?: string;
worksmobileOrgPositionId?: string;
worksmobileOrgIsManager?: boolean;
worksmobilePrimary?: boolean;
gradeNeedsUpdate?: boolean;
};
export type WorksmobileComparison = {
users: WorksmobileComparisonItem[];
groups: WorksmobileComparisonItem[];
+402
View File
@@ -0,0 +1,402 @@
import { performance } from "node:perf_hooks";
import { expect, test } from "@playwright/test";
const tenantCount = 3500;
const userCount = 3500;
type TenantFixture = {
id: string;
name: string;
slug: string;
status: string;
type: string;
memberCount: number;
recursiveMemberCount: number;
createdAt: string;
updatedAt: string;
};
type UserFixture = {
id: string;
name: string;
email: string;
phone: string;
loginId: string;
role: string;
status: string;
tenantId: string;
tenantSlug: string;
tenantName: string;
department: string;
createdAt: string;
};
function buildTenants(): TenantFixture[] {
const baseTime = Date.UTC(2026, 0, 1, 0, 0, 0);
return Array.from({ length: tenantCount }, (_, index) => {
const sequence = index + 1;
const padded = String(sequence).padStart(4, "0");
const timestamp = new Date(baseTime + sequence * 1000).toISOString();
return {
id: `tenant-${padded}`,
name: `Tenant ${padded}`,
slug: sequence === 100 ? "full-dataset-needle-0100" : `tenant-${padded}`,
status: sequence % 17 === 0 ? "inactive" : "active",
type: sequence % 5 === 0 ? "ORGANIZATION" : "COMPANY",
memberCount: sequence % 13,
recursiveMemberCount: sequence % 29,
createdAt: timestamp,
updatedAt: timestamp,
};
});
}
function buildUsers(): UserFixture[] {
const baseTime = Date.UTC(2026, 0, 1, 0, 0, 0);
return Array.from({ length: userCount }, (_, index) => {
const sequence = index + 1;
const padded = String(sequence).padStart(4, "0");
const timestamp = new Date(baseTime + sequence * 1000).toISOString();
const email =
sequence === 100
? "full-dataset-user-needle-0100@example.com"
: `user-${padded}@example.com`;
return {
id: `user-${padded}`,
name: `User ${padded}`,
email,
phone: "010-1111-2222",
loginId: `user-${padded}`,
role: "user",
status: sequence % 19 === 0 ? "inactive" : "active",
tenantId: "tenant-main",
tenantSlug: "tenant-main",
tenantName: "Main Tenant",
department: "Platform",
createdAt: timestamp,
};
});
}
function compareTenantValues(
left: TenantFixture,
right: TenantFixture,
sortKey: string,
) {
const key = sortKey as keyof TenantFixture;
const leftValue = left[key] ?? "";
const rightValue = right[key] ?? "";
return String(leftValue).localeCompare(String(rightValue));
}
test.describe("Tenant list performance", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
}),
);
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("loads and searches the tenant list within the performance budget", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
const tenants = buildTenants();
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.pathname.endsWith("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants") &&
route.request().method() === "GET"
) {
const limit = Number(url.searchParams.get("limit") ?? "500");
const cursor = Number(url.searchParams.get("cursor") ?? "0");
const search = url.searchParams.get("search")?.trim().toLowerCase();
const sort = url.searchParams.get("sort") ?? "createdAt";
const direction = url.searchParams.get("direction") ?? "desc";
let filtered = tenants;
if (search) {
filtered = tenants.filter((tenant) =>
[tenant.id, tenant.name, tenant.slug, tenant.type].some((value) =>
value.toLowerCase().includes(search),
),
);
}
const sorted = [...filtered].sort((left, right) => {
const result = compareTenantValues(left, right, sort);
return direction === "asc" ? result : -result;
});
const pageItems = sorted.slice(cursor, cursor + limit);
const nextOffset = cursor + limit;
return route.fulfill({
json: {
items: pageItems,
total: sorted.length,
limit,
offset: 0,
nextCursor:
nextOffset < sorted.length ? String(nextOffset) : undefined,
},
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
const loadStarted = performance.now();
await page.goto("/tenants");
await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toBeVisible(
{ timeout: 15000 },
);
const loadMs = performance.now() - loadStarted;
const loadSnapshot = testInfo.outputPath("tenant-list-load.png");
await page.screenshot({ path: loadSnapshot, fullPage: true });
await expect(page.locator("tbody tr").first()).toContainText("Tenant 3500");
const searchInput = page.getByPlaceholder("이름 또는 슬러그, ID 검색");
const searchStarted = performance.now();
await searchInput.fill("full-dataset-needle-0100");
await expect(page.getByTestId("tenant-internal-id-tenant-0100")).toBeVisible(
{ timeout: 15000 },
);
const searchMs = performance.now() - searchStarted;
const searchSnapshot = testInfo.outputPath("tenant-list-search.png");
await page.screenshot({ path: searchSnapshot, fullPage: true });
await expect(page.locator("tbody")).toContainText(
"full-dataset-needle-0100",
);
await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toHaveCount(
0,
);
console.log(
JSON.stringify({
metric: "tenant-list-performance",
loadMs: Math.round(loadMs),
searchMs: Math.round(searchMs),
loadSnapshot,
searchSnapshot,
}),
);
expect(loadMs).toBeLessThanOrEqual(1500);
expect(searchMs).toBeLessThanOrEqual(500);
});
});
test.describe("User list performance", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
id_token: "fake-id-token",
access_token: "fake-token",
token_type: "Bearer",
scope: "openid profile email",
profile: {
sub: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 36000,
}),
);
});
await page.route("**/oidc/**", async (route) => {
if (route.request().url().includes("/.well-known/openid-configuration")) {
return route.fulfill({
json: {
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
jwks_uri: "http://localhost:5000/oidc/jwks",
},
});
}
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("loads and searches the user list within the performance budget", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
const users = buildUsers();
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.pathname.endsWith("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants") &&
route.request().method() === "GET"
) {
return route.fulfill({
json: {
items: [
{
id: "tenant-main",
slug: "tenant-main",
name: "Main Tenant",
type: "COMPANY",
config: { userSchema: [] },
},
],
total: 1,
limit: 500,
offset: 0,
},
headers,
});
}
if (
url.pathname.endsWith("/admin/users") &&
route.request().method() === "GET"
) {
const limit = Number(url.searchParams.get("limit") ?? "50");
const cursor = Number(url.searchParams.get("cursor") ?? "0");
const search = url.searchParams.get("search")?.trim().toLowerCase();
const filtered = search
? users.filter((user) =>
[user.id, user.name, user.email, user.loginId].some((value) =>
value.toLowerCase().includes(search),
),
)
: users;
const sorted = [...filtered].sort((left, right) =>
right.createdAt.localeCompare(left.createdAt),
);
const pageItems = sorted.slice(cursor, cursor + limit);
const nextOffset = cursor + limit;
return route.fulfill({
json: {
items: pageItems,
total: sorted.length,
limit,
offset: 0,
nextCursor:
nextOffset < sorted.length ? String(nextOffset) : undefined,
},
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
const loadStarted = performance.now();
await page.goto("/users");
await expect(page.getByTestId("user-internal-id-user-3500")).toBeVisible({
timeout: 15000,
});
const loadMs = performance.now() - loadStarted;
const loadSnapshot = testInfo.outputPath("user-list-load.png");
await page.screenshot({ path: loadSnapshot, fullPage: true });
await expect(page.getByText("User 3500")).toBeVisible();
const searchInput = page.getByPlaceholder("이름 또는 이메일 검색");
const searchStarted = performance.now();
await searchInput.fill("full-dataset-user-needle-0100");
await expect(page.getByTestId("user-internal-id-user-0100")).toBeVisible({
timeout: 15000,
});
const searchMs = performance.now() - searchStarted;
const searchSnapshot = testInfo.outputPath("user-list-search.png");
await page.screenshot({ path: searchSnapshot, fullPage: true });
await expect(
page.getByText("full-dataset-user-needle-0100@example.com"),
).toBeVisible();
await expect(page.getByTestId("user-internal-id-user-3500")).toHaveCount(0);
console.log(
JSON.stringify({
metric: "user-list-performance",
loadMs: Math.round(loadMs),
searchMs: Math.round(searchMs),
loadSnapshot,
searchSnapshot,
}),
);
expect(loadMs).toBeLessThanOrEqual(1500);
expect(searchMs).toBeLessThanOrEqual(500);
});
});
@@ -0,0 +1,219 @@
import fs from "node:fs";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { expect, test, type Route } from "@playwright/test";
const targetTenantId =
process.env.TENANT_PROFILE_PERF_TENANT_ID ??
"56cd0fd7-b62a-43c0-8db9-74a30468d7cb";
const actualApiBaseUrl =
process.env.TENANT_PROFILE_PERF_API_BASE_URL ?? "http://localhost:5173/api";
const normalizedActualApiBaseUrl = actualApiBaseUrl.replace(/\/$/, "");
const evidenceDir = path.resolve("e2e-evidence");
type ApiTiming = {
method: string;
url: string;
status: number;
durationMs: number;
};
type Measurement = {
sample: number;
configFieldsVisibleMs: number;
networkIdleMs: number;
orgUnitType: string | null;
visibility: string | null;
worksmobileSync: string | null;
apiTimings: ApiTiming[];
};
async function fulfillFromLocalApi(route: Route, targetUrl?: string) {
const request = route.request();
const corsHeaders = {
"access-control-allow-headers": "authorization,content-type,x-test-role",
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
"access-control-allow-origin": "*",
};
if (request.method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: corsHeaders });
return;
}
const headers = { ...request.headers(), "x-test-role": "super_admin" };
delete headers.authorization;
delete headers.host;
const response = await route.fetch({ url: targetUrl, headers });
await route.fulfill({
response,
headers: { ...response.headers(), ...corsHeaders },
});
}
function percentile(values: number[], ratio: number) {
const sorted = [...values].sort((left, right) => left - right);
const index = Math.min(
sorted.length - 1,
Math.ceil(sorted.length * ratio) - 1,
);
return sorted[index] ?? 0;
}
test.describe("Tenant profile local performance evidence", () => {
test("loads org config fields through the local API within 500ms", async ({
page,
}, testInfo) => {
fs.mkdirSync(evidenceDir, { recursive: true });
await page.setViewportSize({ width: 1440, height: 900 });
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
window.localStorage.setItem("X-Mock-Role", "super_admin");
window.localStorage.removeItem("admin_session");
for (const key of Object.keys(window.localStorage)) {
if (key.startsWith("oidc.user:")) {
window.localStorage.removeItem(key);
}
}
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route("**/api/**", async (route) => {
await fulfillFromLocalApi(route);
});
await page.route("http://playwright-mock/api/**", async (route) => {
const request = route.request();
const source = new URL(request.url());
const target = `${normalizedActualApiBaseUrl}${source.pathname.replace(
/^\/api/,
"",
)}${source.search}`;
await fulfillFromLocalApi(route, target);
});
const requestStartedAt = new Map<string, number>();
const apiTimings: ApiTiming[] = [];
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/v1/") || url.includes("playwright-mock/api")) {
requestStartedAt.set(request.url(), performance.now());
}
});
page.on("response", (response) => {
const request = response.request();
const startedAt = requestStartedAt.get(request.url());
if (startedAt === undefined) {
return;
}
const timing = {
method: request.method(),
url: response.url(),
status: response.status(),
durationMs: Math.round(performance.now() - startedAt),
};
apiTimings.push(timing);
});
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("/api/v1/") || url.includes("playwright-mock/api")) {
console.log(
"api-request-failed",
JSON.stringify({
method: request.method(),
url,
failure: request.failure()?.errorText,
}),
);
}
});
const measurements: Measurement[] = [];
const sampleCount = 5;
for (let sample = 1; sample <= sampleCount; sample += 1) {
apiTimings.length = 0;
const startedAt = performance.now();
await page.goto(`/tenants/${targetTenantId}`, {
waitUntil: "domcontentloaded",
});
const orgUnitTypeSelect = page.getByTestId("tenant-org-unit-type-select");
await expect(orgUnitTypeSelect).toBeVisible({ timeout: 15000 });
await expect(page.locator("#tenant-visibility")).toBeVisible();
await expect(page.locator("#worksmobileExcluded")).toBeVisible();
const configFieldsVisibleMs = Math.round(performance.now() - startedAt);
await page.waitForLoadState("networkidle", { timeout: 15000 });
const networkIdleMs = Math.round(performance.now() - startedAt);
measurements.push({
sample,
configFieldsVisibleMs,
networkIdleMs,
orgUnitType: await orgUnitTypeSelect.inputValue(),
visibility: await page.locator("#tenant-visibility").inputValue(),
worksmobileSync: await page
.locator("#worksmobileExcluded")
.inputValue(),
apiTimings: [...apiTimings],
});
}
const screenshotPath = path.join(
evidenceDir,
"tenant-profile-performance-local.png",
);
await page.screenshot({ path: screenshotPath, fullPage: true });
const configTimes = measurements.map(
(measurement) => measurement.configFieldsVisibleMs,
);
const networkIdleTimes = measurements.map(
(measurement) => measurement.networkIdleMs,
);
const evidence = {
metric: "tenant-profile-local-performance",
tenantId: targetTenantId,
actualApiBaseUrl,
measuredAt: new Date().toISOString(),
browser: testInfo.project.name,
samples: measurements,
summary: {
configFieldsVisibleMs: {
min: Math.min(...configTimes),
max: Math.max(...configTimes),
p50: percentile(configTimes, 0.5),
p95: percentile(configTimes, 0.95),
},
networkIdleMs: {
min: Math.min(...networkIdleTimes),
max: Math.max(...networkIdleTimes),
p50: percentile(networkIdleTimes, 0.5),
p95: percentile(networkIdleTimes, 0.95),
},
},
screenshotPath,
};
const evidencePath = path.join(
evidenceDir,
"tenant-profile-performance-local.json",
);
fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
console.log(JSON.stringify(evidence, null, 2));
expect(evidence.summary.configFieldsVisibleMs.p95).toBeLessThanOrEqual(500);
});
});
+87
View File
@@ -527,6 +527,93 @@ test.describe("Tenants Management", () => {
);
});
test("should bulk update selected tenant status type and visibility", async ({
page,
}) => {
await page.setViewportSize({ width: 1100, height: 760 });
const updatePayloads: Record<string, unknown>[] = [];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "PUT") {
updatePayloads.push(request.postDataJSON());
return route.fulfill({
json: {
id: url.pathname.split("/").at(-1),
name: "Updated Tenant",
slug: "updated-tenant",
status: "inactive",
type: "ORGANIZATION",
config: { visibility: "public" },
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}
if (request.method() !== "GET") {
return route.continue();
}
return route.fulfill({
json: {
items: [
{
id: "tenant-a",
name: "Tenant A",
slug: "tenant-a",
status: "active",
type: "COMPANY",
config: { visibility: "internal" },
updatedAt: new Date().toISOString(),
},
{
id: "tenant-b",
name: "Tenant B",
slug: "tenant-b",
status: "active",
type: "COMPANY",
config: { visibility: "internal" },
updatedAt: new Date().toISOString(),
},
],
total: 2,
limit: 500,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/tenants");
for (const tenantId of ["tenant-a", "tenant-b"]) {
await page
.getByTestId(`tenant-internal-id-${tenantId}`)
.locator("xpath=ancestor::tr")
.getByRole("checkbox")
.click();
}
await page.getByTestId("tenant-bulk-status-select").click();
await page.getByRole("option", { name: /비활성|inactive/i }).click();
await page.getByTestId("tenant-bulk-type-select").click();
await page.getByRole("option", { name: /Organization|정규 조직/i }).click();
await page.getByTestId("tenant-bulk-visibility-select").click();
await page.getByRole("option", { name: "공개", exact: true }).click();
await page.getByTestId("tenant-bulk-apply-btn").click();
await expect.poll(() => updatePayloads).toHaveLength(2);
for (const payload of updatePayloads) {
expect(payload).toMatchObject({
status: "inactive",
type: "ORGANIZATION",
config: { visibility: "public" },
});
}
});
test("switches tree and flat views, searches UUID, and selects descendants", async ({
page,
}) => {
+310 -3
View File
@@ -754,7 +754,7 @@ test.describe("User Management", () => {
expect(exportUrl).toContain("includeIds=false");
});
test("should show contact info in one row, hide roles, and change user status", async ({
test("should hide role controls from the users table and change user status", async ({
page,
}) => {
let updatePayload: Record<string, unknown> | undefined;
@@ -781,13 +781,311 @@ test.describe("User Management", () => {
const table = page.locator("table");
await expect(
table.getByRole("columnheader", { name: /ROLE|역할/i }),
).toBeVisible();
).toHaveCount(0);
await page.getByTestId("user-status-select-u-1").click();
await page.getByRole("option", { name: /입사대기|Preboarding/ }).click();
await expect
.poll(() => updatePayload)
.toMatchObject({ status: "preboarding" });
await table.locator('input[name="user-list-select-u-1"]').check();
await expect(page.getByTestId("bulk-permission-select")).toHaveCount(0);
});
test("should keep system role assignment out of the permissions screen", async ({
page,
}) => {
let bulkPayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/system\/relations$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
userId: "u-1",
name: "John Doe",
email: "john@test.com",
relations: ["overview_viewers"],
},
],
},
});
});
await page.route(/\/admin\/users\/bulk$/, async (route) => {
if (route.request().method() !== "PUT") {
return route.fallback();
}
bulkPayload = route.request().postDataJSON();
return route.fulfill({
json: { results: [{ userId: "u-1", success: true }] },
});
});
await page.goto("/permissions-direct");
await expect(
page.getByTestId("permission-assignment-row-u-1-overview_viewers"),
).toBeVisible();
await expect(
page.getByTestId("permissions-direct-super-admin-select"),
).toHaveCount(0);
expect(bulkPayload).toBeUndefined();
});
test("should support bulk page and target action grants while keeping permissions direct protected", async ({
page,
}) => {
const relationWrites: Array<Record<string, unknown>> = [];
const relationDeletes: Array<Record<string, unknown>> = [];
await page.route(/\/admin\/system\/relations$/, async (route) => {
const method = route.request().method();
if (method === "GET") {
return route.fulfill({
json: {
items: [
{
userId: "u-1",
name: "John Doe",
email: "john@test.com",
relations: ["overview_viewers"],
},
{
userId: "u-2",
name: "Jane Manager",
email: "jane@test.com",
relations: [],
},
],
},
});
}
if (method === "POST") {
relationWrites.push(route.request().postDataJSON());
return route.fulfill({ json: { success: true } });
}
if (method === "DELETE") {
relationDeletes.push(route.request().postDataJSON());
return route.fulfill({ json: { success: true } });
}
return route.fallback();
});
await page.route(/\/admin\/tenants\/t-1\/relations$/, async (route) => {
const method = route.request().method();
if (method === "GET") {
return route.fulfill({
json: {
items: [
{
userId: "u-1",
name: "John Doe",
email: "john@test.com",
relations: ["profile_viewers"],
},
],
},
});
}
if (method === "POST") {
relationWrites.push(route.request().postDataJSON());
return route.fulfill({ json: { success: true } });
}
if (method === "DELETE") {
relationDeletes.push(route.request().postDataJSON());
return route.fulfill({ json: { success: true } });
}
return route.fallback();
});
await page.goto("/permissions-direct");
await expect(page.getByRole("tab", { name: /상세 권한/ })).toBeVisible();
await expect(
page.getByRole("option", { name: /권한 부여.*수정/ }),
).toHaveCount(0);
await expect(page.getByTestId("permission-target-org-picker-frame")).toBeVisible();
await expect(page.getByTestId("permission-target-org-picker-frame")).toHaveAttribute(
"src",
/rootTenantId%3Dall|rootTenantId=all/,
);
const pickerBox = await page
.getByTestId("permission-target-org-picker-frame")
.boundingBox();
const queueBox = await page.getByTestId("permission-target-queue").boundingBox();
expect(pickerBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan(
queueBox?.x ?? Number.NEGATIVE_INFINITY,
);
await page.getByTestId("bulk-relation-mode").selectOption("target-action");
await expect(
page.getByTestId("bulk-relation-operation"),
).toHaveCount(0);
await page.getByTestId("permission-action-tenant-picker-open").click();
await page.getByTestId("permission-action-tenant-search").fill("Test");
await page.getByTestId("permission-action-tenant-result-t-1").click();
await expect(page.getByTestId("bulk-relation-target-tenant")).toHaveValue(
"t-1",
);
await expect(
page.getByTestId("permission-target-tenant-scope"),
).toHaveCount(0);
await expect(
page.getByTestId("permission-target-org-picker-frame"),
).not.toHaveAttribute("src", /tenantId%3Dt-1|tenantId=t-1/);
await page.evaluate(() => {
window.postMessage(
{
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "user",
id: "u-2",
name: "Jane Manager",
email: "jane@test.com",
rootTenantName: "한맥가족",
leafTenantName: "기술기획",
},
{
type: "user",
id: "u-3",
name: "Org Picked User",
email: "picked@test.com",
rootTenantName: "Commercial",
leafTenantName: "디자인팀",
},
],
},
},
"*",
);
});
await expect(page.getByTestId("permission-target-queue")).toContainText(
"Jane Manager",
);
await expect(page.getByTestId("permission-target-queue")).toContainText(
"Org Picked User",
);
await expect(page.getByTestId("permission-target-queue")).toContainText(
"한맥가족 / 기술기획",
);
await page.getByTestId("bulk-relation-target").selectOption("profile");
await page.getByTestId("bulk-relation-action").selectOption("manage");
await page
.getByRole("button", { name: /선택 사용자에게 권한 부여/ })
.click();
await expect.poll(() => relationWrites).toContainEqual(
{ userId: "u-2", relation: "tenants_managers" },
);
await expect.poll(() => relationWrites).toContainEqual(
{ userId: "u-2", relation: "profile_managers" },
);
await expect.poll(() => relationWrites).toContainEqual(
{ userId: "u-3", relation: "profile_managers" },
);
await page.getByTestId("permission-assignment-search").fill("John");
await expect(page.getByTestId("permission-assignment-row-u-1-profile_viewers")).toBeVisible();
await expect(
page.getByTestId("permission-assignment-row-u-2-profile_managers"),
).toHaveCount(0);
await page.getByTestId("permission-assignment-search").fill("");
await page.getByTestId("permission-assignment-sort").selectOption("relation");
await page
.getByTestId("permission-assignment-level-u-1-profile_viewers")
.selectOption("write");
await expect.poll(() => relationWrites).toContainEqual({
userId: "u-1",
relation: "profile_managers",
});
await page
.getByTestId("permission-assignment-remove-u-1-profile_viewers")
.click();
await expect.poll(() => relationDeletes).toContainEqual({
userId: "u-1",
relation: "profile_viewers",
});
});
test("should grant super admin role from the last tab only for super admins", async ({
page,
}) => {
let bulkPayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/system\/relations$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
userId: "u-1",
name: "John Doe",
email: "john@test.com",
relations: ["overview_viewers"],
},
],
},
});
});
await page.route(/\/admin\/users\/bulk$/, async (route) => {
if (route.request().method() !== "PUT") {
return route.fallback();
}
bulkPayload = route.request().postDataJSON();
return route.fulfill({
json: { results: [{ userId: "u-1", success: true }] },
});
});
await page.goto("/permissions-direct");
const tabs = page.getByRole("tab");
await expect(tabs.last()).toHaveText(/Super Admin 역할/);
await tabs.last().click();
await page.getByTestId("super-admin-role-user-u-1").check();
await page.getByRole("button", { name: /Super Admin 부여/ }).click();
await expect.poll(() => bulkPayload).toEqual({
userIds: ["u-1"],
role: "super_admin",
});
});
test("should hide the super admin role tab from non super admins", async ({
page,
}) => {
await page.route(/\/user\/me$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
id: "operator-user",
name: "Operator",
email: "operator@test.com",
role: "user",
manageableTenants: [],
},
});
});
await page.goto("/permissions-direct");
await expect(
page.getByRole("tab", { name: /Super Admin 역할/ }),
).toHaveCount(0);
await expect(
page.getByText(/이 작업을 수행할 권한이 없습니다/),
).toBeVisible();
});
test("should center users table loading state and use compact headers", async ({
@@ -1222,8 +1520,17 @@ test.describe("User Management", () => {
await page.goto("/users/u-1");
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
page.getByRole("tab", { name: /^한맥가족$/i }),
).toHaveAttribute("data-state", "active");
await expect(
page.getByRole("tab", { name: /외부 기업 회원/i }),
).toHaveCount(0);
await expect(
page.getByRole("tab", { name: /^Commercial$/i }),
).toBeVisible();
await expect(page.getByRole("tab", { name: /^공공기관$/i })).toBeVisible();
await expect(page.getByRole("tab", { name: /^교육기관$/i })).toBeVisible();
await expect(page.getByRole("tab", { name: /^개인$/i })).toBeVisible();
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
await expect(
+2
View File
@@ -4,6 +4,7 @@ import { defineConfig } from "vite";
const buildOutDir =
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist";
const usePolling = process.env.DEV_SERVER_WATCH_POLLING === "true";
export default defineConfig({
plugins: [react()],
@@ -24,6 +25,7 @@ export default defineConfig({
},
server: {
host: "127.0.0.1",
watch: usePolling ? { interval: 300, usePolling: true } : undefined,
proxy: {
"/api": {
target: process.env.API_PROXY_TARGET || "http://localhost:3000",
+149 -4
View File
@@ -43,6 +43,9 @@ type worksmobileSyncConfig struct {
CreateUsersResultOutput string
CreateUsersLimit int
CreateUsersForcePasswordChange bool
UpdateUserLevelsCSV string
UpdateUserLevelsResultOutput string
UpdateUserLevelsLimit int
ImportHanmacUsersCSV string
ImportHanmacUsersResultOutput string
ImportHanmacUsersPassword string
@@ -168,6 +171,11 @@ func runWorksmobileSync(args []string) error {
return err
}
}
if config.UpdateUserLevelsCSV != "" {
if err := updateWorksmobileUserLevelsFromCSV(ctx, db, tenantRepo, userRepo, *root, config.UpdateUserLevelsCSV, config.UpdateUserLevelsResultOutput, config.UpdateUserLevelsLimit, newWorksmobileAdminClient()); err != nil {
return err
}
}
if config.ImportHanmacUsersCSV != "" {
if err := importHanmacUsersAndCreateWorksmobileAccounts(ctx, db, tenantRepo, userRepo, *root, config.ImportHanmacUsersCSV, config.ImportHanmacUsersResultOutput, config.ImportHanmacUsersPassword, config.ImportHanmacUsersLimit, config.ImportHanmacUsersForcePasswordChange, newWorksmobileAdminClient()); err != nil {
return err
@@ -232,6 +240,9 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
fs.StringVar(&config.CreateUsersResultOutput, "create-users-result-output", "", "output CSV path for Worksmobile create results")
fs.IntVar(&config.CreateUsersLimit, "create-users-limit", 0, "maximum users to create; 0 means all")
fs.BoolVar(&config.CreateUsersForcePasswordChange, "create-users-force-password-change", true, "request password change at next login for --create-users-csv")
fs.StringVar(&config.UpdateUserLevelsCSV, "update-user-levels-csv", "", "CSV containing user_id column to patch Worksmobile user levels directly")
fs.StringVar(&config.UpdateUserLevelsResultOutput, "update-user-levels-result-output", "", "output CSV path for Worksmobile user level patch results")
fs.IntVar(&config.UpdateUserLevelsLimit, "update-user-levels-limit", 0, "maximum users to patch levels; 0 means all")
fs.StringVar(&config.ImportHanmacUsersCSV, "import-hanmac-users-csv", "", "CSV containing Hanmac internal users to upsert into Baron and create in Worksmobile")
fs.StringVar(&config.ImportHanmacUsersResultOutput, "import-hanmac-users-result-output", "", "output CSV path for Hanmac user import and Worksmobile create results")
fs.StringVar(&config.ImportHanmacUsersPassword, "import-hanmac-users-password", "", "initial password for --import-hanmac-users-csv")
@@ -256,8 +267,8 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
if err := fs.Parse(args); err != nil {
return config, err
}
if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process {
return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process")
if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process {
return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process")
}
if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" {
return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password")
@@ -277,6 +288,9 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
if config.CreateUsersCSV != "" && config.CreateUsersResultOutput == "" {
return config, fmt.Errorf("--create-users-result-output is required with --create-users-csv")
}
if config.UpdateUserLevelsCSV != "" && config.UpdateUserLevelsResultOutput == "" {
return config, fmt.Errorf("--update-user-levels-result-output is required with --update-user-levels-csv")
}
if config.ImportHanmacUsersCSV != "" && config.ImportHanmacUsersPassword == "" {
return config, fmt.Errorf("--import-hanmac-users-password is required with --import-hanmac-users-csv")
}
@@ -1346,6 +1360,134 @@ func createWorksmobileUsersFromCSV(ctx context.Context, db *gorm.DB, tenantRepo
return nil
}
func updateWorksmobileUserLevelsFromCSV(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, userRepo repository.UserRepository, root domain.Tenant, usersCSV string, outputPath string, limit int, client *service.WorksmobileHTTPClient) error {
if limit < 0 {
return fmt.Errorf("--update-user-levels-limit cannot be negative")
}
userIDs, err := readWorksmobileUserIDsCSV(usersCSV)
if err != nil {
return err
}
if limit > 0 && len(userIDs) > limit {
userIDs = userIDs[:limit]
}
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID)
if err != nil {
return err
}
tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs)
if err != nil {
return err
}
tenantByID := map[string]domain.Tenant{root.ID: root}
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
}
users, err := userRepo.FindByIDs(ctx, userIDs)
if err != nil {
return err
}
userByID := map[string]domain.User{}
for _, user := range users {
userByID[user.ID] = user
}
file, err := os.Create(outputPath)
if err != nil {
return err
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
header := []string{"user_id", "email", "name", "domain_id", "baron_level", "status", "error"}
if err := writer.Write(header); err != nil {
return err
}
okCount := 0
skippedCount := 0
errorCount := 0
for _, userID := range userIDs {
status := "ok"
errorMessage := ""
email := ""
name := ""
domainID := ""
levelName := ""
user, ok := userByID[userID]
if !ok {
status = "skipped"
errorMessage = "baron user not found"
skippedCount++
} else {
email = strings.TrimSpace(user.Email)
name = strings.TrimSpace(user.Name)
if user.TenantID == nil {
status = "skipped"
errorMessage = "baron user has no tenant"
skippedCount++
} else if !domain.IsWorksProvisionedUserStatus(user.Status) {
status = "skipped"
errorMessage = "baron user status is excluded from Worksmobile sync"
skippedCount++
} else {
tenant, ok := tenantByID[*user.TenantID]
if !ok {
status = "skipped"
errorMessage = "baron user tenant is outside Worksmobile sync scope"
skippedCount++
} else {
payload, err := service.BuildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, root.Config)
if err != nil {
status = "skipped"
errorMessage = err.Error()
skippedCount++
} else {
levelDomainID := worksmobileUserLevelPatchDomainID(payload)
domainID = fmt.Sprint(levelDomainID)
levelName = strings.TrimSpace(payload.LevelID)
expectedLevelName := service.WorksmobileLevelDisplayNameForIdentifier(levelName)
if levelName == "" {
status = "skipped"
errorMessage = "baron user has no level"
skippedCount++
} else if err := client.PatchUserOrganizationLevelByName(ctx, payload.Email, levelDomainID, levelName); err != nil {
status = "error"
errorMessage = err.Error()
errorCount++
} else if remote, err := client.GetUser(ctx, payload.Email); err != nil {
status = "error"
errorMessage = err.Error()
errorCount++
} else if !service.WorksmobileLevelIdentifierMatchesRemote(levelName, remote.LevelID, remote.LevelName) {
status = "error"
errorMessage = fmt.Sprintf("worksmobile level verification failed: expected_level=%s expected_level_name=%s remote_level_id=%s remote_level_name=%s", levelName, expectedLevelName, strings.TrimSpace(remote.LevelID), strings.TrimSpace(remote.LevelName))
errorCount++
} else {
okCount++
}
}
}
}
}
if err := writer.Write([]string{userID, email, name, domainID, levelName, status, errorMessage}); err != nil {
return err
}
writer.Flush()
if err := writer.Error(); err != nil {
return err
}
}
fmt.Printf("worksmobile user levels update result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, len(userIDs), okCount, skippedCount, errorCount)
return nil
}
func worksmobileUserLevelPatchDomainID(payload service.WorksmobileUserPayload) int64 {
if payload.LevelDomainID > 0 {
return payload.LevelDomainID
}
return payload.DomainID
}
type hanmacWorksmobileImportRow struct {
Email string
Name string
@@ -1698,6 +1840,7 @@ func applyHanmacWorksmobileImportRowToUser(user *domain.User, row hanmacWorksmob
"tenantId": tenant.ID,
"tenantSlug": tenant.Slug,
"tenantName": tenant.Name,
"grade": strings.TrimSpace(row.Grade),
"isPrimary": true,
}}
}
@@ -2300,6 +2443,7 @@ func exportWorksmobileNeedsUpdateComparison(ctx context.Context, syncService ser
"baron_id",
"baron_name",
"baron_email",
"baron_grade",
"baron_primary_org_id",
"baron_primary_org_slug",
"baron_primary_org_name",
@@ -2336,6 +2480,7 @@ func exportWorksmobileNeedsUpdateComparison(ctx context.Context, syncService ser
item.BaronID,
item.BaronName,
item.BaronEmail,
item.BaronGrade,
item.BaronPrimaryOrgID,
item.BaronPrimaryOrgSlug,
item.BaronPrimaryOrgName,
@@ -3061,7 +3206,7 @@ func newWorksmobileAdminClient() *service.WorksmobileHTTPClient {
},
)
client.BaseURL = strings.TrimSpace(getenv("WORKS_ADMIN_API_BASE_URL", ""))
client.RateLimiter = service.NewWorksmobileAPIRateLimiter(180, time.Minute)
client.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
return client
}
@@ -3071,7 +3216,7 @@ func newWorksmobileSCIMClient() *service.WorksmobileHTTPClient {
getenv("WORKS_ADMIN_SCIM_TOKEN", getenv("SAMAN_SCIM_LONGLIVE_TOKEN", "")),
)
client.BaseURL = strings.TrimSpace(getenv("WORKS_ADMIN_API_BASE_URL", ""))
client.RateLimiter = service.NewWorksmobileAPIRateLimiter(180, time.Minute)
client.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
return client
}
@@ -36,3 +36,14 @@ func TestClassifyWorksmobileAlignFromWorksSkipsLocalPartChange(t *testing.T) {
t.Fatalf("expected skipped_email_local_part_changed status, got %s", status)
}
}
func TestWorksmobileUserLevelPatchDomainIDPrefersLevelDomain(t *testing.T) {
payload := service.WorksmobileUserPayload{
DomainID: 300285955,
LevelDomainID: 300286337,
}
if got := worksmobileUserLevelPatchDomainID(payload); got != 300286337 {
t.Fatalf("expected level domain id, got %d", got)
}
}
+36 -5
View File
@@ -91,11 +91,6 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
parentSlug: "baron-group",
domains: []string{"pre-cast.co.kr"},
},
{
name: "Personal",
slug: "personal",
tenantType: domain.TenantTypePersonal,
},
}
if len(configs) < len(expected) {
@@ -161,6 +156,42 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
}
}
func TestSeedTenantCSVDefinesTopLevelSeedTenantStructure(t *testing.T) {
configs, err := loadSeedTenantConfigs()
if err != nil {
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
}
configBySlug := make(map[string]InitialTenantConfig, len(configs))
for _, config := range configs {
configBySlug[config.Slug] = config
}
expectedRoots := []struct {
slug string
tenantType string
}{
{slug: "hanmac-family", tenantType: domain.TenantTypeCompanyGroup},
{slug: "commercial", tenantType: domain.TenantTypeCompanyGroup},
{slug: "public-org", tenantType: domain.TenantTypeCompanyGroup},
{slug: "edu", tenantType: domain.TenantTypeCompanyGroup},
{slug: "personal", tenantType: domain.TenantTypePersonal},
}
for _, want := range expectedRoots {
got, ok := configBySlug[want.slug]
if !ok {
t.Fatalf("top-level seed tenant slug %q not found", want.slug)
}
if got.Type != want.tenantType {
t.Fatalf("top-level seed tenant[%s] type = %q, want %q", want.slug, got.Type, want.tenantType)
}
if got.ParentSlug != "" {
t.Fatalf("top-level seed tenant[%s] parent slug = %q, want empty", want.slug, got.ParentSlug)
}
}
}
func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
if got := normalizeSeedTenantType("organization"); got != domain.TenantTypeOrganization {
t.Fatalf("normalizeSeedTenantType(organization) = %q, want %q", got, domain.TenantTypeOrganization)
-5
View File
@@ -8038,11 +8038,6 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
if department := extractTraitString(traits, "department"); department != "" {
localUser.Department = department
}
if grade := extractTraitString(traits, "grade"); grade != "" {
if _, isRole := domain.NormalizeRoleAlias(grade); !isRole {
localUser.Grade = grade
}
}
if position := extractTraitString(traits, "position"); position != "" {
localUser.Position = position
}
+566 -94
View File
@@ -9,6 +9,7 @@ import (
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/base64"
"encoding/csv"
"encoding/json"
"errors"
@@ -66,6 +67,16 @@ func seedTenantSlugsForDeleteGuard() []string {
return result
}
func tenantCreatorIDForMembership(profile *domain.UserProfileResponse) string {
if profile == nil {
return ""
}
if domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
return ""
}
return strings.TrimSpace(profile.ID)
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
return &TenantHandler{
DB: db,
@@ -125,6 +136,20 @@ type tenantListResponse struct {
NextCursor string `json:"nextCursor,omitempty"`
}
type tenantSortDirection string
const (
tenantSortAsc tenantSortDirection = "asc"
tenantSortDesc tenantSortDirection = "desc"
)
type tenantQueryCursor struct {
Sort string `json:"sort"`
Direction string `json:"direction"`
Value string `json:"value"`
ID string `json:"id"`
}
type orgChartSnapshotCacheInfo struct {
Source string `json:"source"`
Hit bool `json:"hit"`
@@ -132,9 +157,10 @@ type orgChartSnapshotCacheInfo struct {
}
type orgChartSnapshotResponse struct {
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
Cache orgChartSnapshotCacheInfo `json:"cache"`
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
GeneratedAt string `json:"generatedAt"`
Cache orgChartSnapshotCacheInfo `json:"cache"`
}
func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ([]domain.Tenant, string, error) {
@@ -147,6 +173,189 @@ func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) (
})
}
func normalizeTenantSortDirection(raw string) tenantSortDirection {
if strings.EqualFold(strings.TrimSpace(raw), string(tenantSortAsc)) {
return tenantSortAsc
}
return tenantSortDesc
}
func normalizeTenantSortKey(raw string) string {
switch strings.TrimSpace(raw) {
case "id", "name", "slug", "type", "status", "createdAt", "updatedAt":
return strings.TrimSpace(raw)
default:
return "createdAt"
}
}
func encodeTenantQueryCursor(sortKey string, direction tenantSortDirection, value string, id string) string {
if strings.TrimSpace(value) == "" || strings.TrimSpace(id) == "" {
return ""
}
payload, err := json.Marshal(tenantQueryCursor{
Sort: sortKey,
Direction: string(direction),
Value: value,
ID: id,
})
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func decodeTenantQueryCursor(raw string, sortKey string, direction tenantSortDirection) (*tenantQueryCursor, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
decoded, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return nil, err
}
var cursor tenantQueryCursor
if err := json.Unmarshal(decoded, &cursor); err != nil {
return nil, err
}
if strings.TrimSpace(cursor.ID) == "" || strings.TrimSpace(cursor.Value) == "" {
return nil, errors.New("invalid tenant cursor")
}
if cursor.Sort != sortKey || cursor.Direction != string(direction) {
return nil, errors.New("tenant cursor does not match sort")
}
return &cursor, nil
}
func tenantCursorValue(tenant domain.Tenant, sortKey string) string {
switch sortKey {
case "id":
return tenant.ID
case "name":
return strings.ToLower(tenant.Name)
case "slug":
return strings.ToLower(tenant.Slug)
case "type":
return strings.ToLower(tenant.Type)
case "status":
return strings.ToLower(tenant.Status)
case "updatedAt":
return tenant.UpdatedAt.UTC().Format(time.RFC3339Nano)
default:
return tenant.CreatedAt.UTC().Format(time.RFC3339Nano)
}
}
func filterTenantsByListSearch(tenants []domain.Tenant, search string) []domain.Tenant {
search = strings.ToLower(strings.TrimSpace(search))
if search == "" {
return tenants
}
filtered := make([]domain.Tenant, 0, len(tenants))
for _, tenant := range tenants {
if strings.Contains(strings.ToLower(tenant.ID), search) ||
strings.Contains(strings.ToLower(tenant.Name), search) ||
strings.Contains(strings.ToLower(tenant.Slug), search) ||
strings.Contains(strings.ToLower(tenant.Type), search) ||
strings.Contains(strings.ToLower(tenant.Description), search) {
filtered = append(filtered, tenant)
}
}
return filtered
}
func sortTenantsForList(tenants []domain.Tenant, sortKey string, direction tenantSortDirection) {
sort.SliceStable(tenants, func(i, j int) bool {
left := tenantCursorValue(tenants[i], sortKey)
right := tenantCursorValue(tenants[j], sortKey)
if left == right {
if direction == tenantSortAsc {
return tenants[i].ID < tenants[j].ID
}
return tenants[i].ID > tenants[j].ID
}
if direction == tenantSortAsc {
return left < right
}
return left > right
})
}
func pageSortedTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, string, error) {
ordered := append([]domain.Tenant(nil), tenants...)
sortTenantsForList(ordered, sortKey, direction)
cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
if err != nil {
return nil, "", err
}
if cursor != nil {
filtered := ordered[:0]
for _, tenant := range ordered {
value := tenantCursorValue(tenant, sortKey)
after := value > cursor.Value || (value == cursor.Value && tenant.ID > cursor.ID)
if direction == tenantSortDesc {
after = value < cursor.Value || (value == cursor.Value && tenant.ID < cursor.ID)
}
if after {
filtered = append(filtered, tenant)
}
}
ordered = filtered
}
if len(ordered) <= limit {
return ordered, "", nil
}
page := ordered[:limit]
last := page[len(page)-1]
return page, encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID), nil
}
func tenantSortExpression(sortKey string) string {
switch sortKey {
case "id":
return "id::text"
case "name":
return "LOWER(name)"
case "slug":
return "LOWER(slug)"
case "type":
return "LOWER(type)"
case "status":
return "LOWER(status)"
case "updatedAt":
return "updated_at"
default:
return "created_at"
}
}
func tenantOrderClause(sortExpression string, direction tenantSortDirection) string {
if direction == tenantSortAsc {
return sortExpression + " asc, id asc"
}
return sortExpression + " desc, id desc"
}
func applyTenantQueryCursor(db *gorm.DB, sortExpression string, cursor *tenantQueryCursor, direction tenantSortDirection) *gorm.DB {
if cursor == nil {
return db
}
operator := "<"
idOperator := "<"
if direction == tenantSortAsc {
operator = ">"
idOperator = ">"
}
return db.Where(
fmt.Sprintf("%s %s ? OR (%s = ? AND id::text %s ?)", sortExpression, operator, sortExpression, idOperator),
cursor.Value,
cursor.Value,
cursor.ID,
)
}
type tenantImportDetail struct {
Row int `json:"row"`
Slug string `json:"slug"`
@@ -289,6 +498,9 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
offset := c.QueryInt("offset", 0)
parentId := c.Query("parentId")
cursorRaw := strings.TrimSpace(c.Query("cursor"))
search := strings.TrimSpace(c.Query("search"))
sortKey := normalizeTenantSortKey(c.Query("sort"))
sortDirection := normalizeTenantSortDirection(c.Query("direction"))
if limit <= 0 {
limit = 50
@@ -298,6 +510,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
var tenants []domain.Tenant
var countSourceTenants []domain.Tenant
var total int64
var err error
nextCursor := ""
@@ -365,44 +578,55 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
tenants = filterTenantsByListSearch(tenants, search)
countSourceTenants = append([]domain.Tenant(nil), tenants...)
total = int64(len(tenants))
if cursorRaw != "" {
tenants, nextCursor, err = pageTenantsByCursor(tenants, limit, cursorRaw)
tenants, nextCursor, err = pageSortedTenantsByCursor(tenants, limit, cursorRaw, sortKey, sortDirection)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else if offset < len(tenants) {
sortTenantsForList(tenants, sortKey, sortDirection)
end := min(offset+limit, len(tenants))
tenants = tenants[offset:end]
if total > int64(end) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
}
} else {
tenants = []domain.Tenant{}
}
} else {
// Super Admin case
if cursorRaw != "" && h.DB != nil {
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw, "")
if h.DB != nil {
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, offset, parentId, cursorRaw, search, sortKey, sortDirection)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
if cursorRaw != "" {
offset = 0
}
} else {
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, "")
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, search)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if total > int64(offset+len(tenants)) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
}
}
}
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
var memberCounts map[string]int64
var totalMemberCounts map[string]int64
if countSourceTenants != nil {
memberCounts, totalMemberCounts, err = h.countTenantMembersFromTenantSet(c.Context(), tenants, countSourceTenants)
} else {
memberCounts, totalMemberCounts, err = h.countTenantMembers(c.Context(), tenants)
}
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -425,8 +649,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
})
}
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string, search string) ([]domain.Tenant, int64, string, error) {
cursor, err := pagination.Decode(cursorRaw)
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, offset int, parentID string, cursorRaw string, search string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, int64, string, error) {
cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
if err != nil {
return nil, 0, "", err
}
@@ -440,8 +664,9 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
if search != "" {
searchTerm := "%" + strings.ToLower(search) + "%"
countQuery = countQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
pageQuery = pageQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
searchClause := "LOWER(id::text) LIKE ? OR LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(type) LIKE ? OR LOWER(description) LIKE ?"
countQuery = countQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
pageQuery = pageQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
}
var total int64
@@ -449,11 +674,16 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
return nil, 0, "", err
}
pageQuery = pagination.ApplyCreatedAtIDCursor(pageQuery, cursor, "created_at", "id")
sortExpression := tenantSortExpression(sortKey)
if cursor != nil {
pageQuery = applyTenantQueryCursor(pageQuery, sortExpression, cursor, direction)
} else if offset > 0 {
pageQuery = pageQuery.Offset(offset)
}
var tenants []domain.Tenant
if err := pageQuery.
Order("created_at desc, id desc").
Order(tenantOrderClause(sortExpression, direction)).
Limit(limit + 1).
Preload("Domains").
Find(&tenants).Error; err != nil {
@@ -464,7 +694,7 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
if len(tenants) > limit {
tenants = tenants[:limit]
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
nextCursor = encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID)
}
return tenants, total, nextCursor, nil
}
@@ -609,8 +839,8 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
records = orderTenantCSVRecordsByParentSlug(records)
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
creatorID = profile.ID
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
creatorID = tenantCreatorIDForMembership(profile)
}
tenantIDBySlug := make(map[string]string)
@@ -1861,10 +2091,9 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
parentID = &pid
}
// Extract creator ID if present
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
creatorID = profile.ID
creatorID = tenantCreatorIDForMembership(profile)
}
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
@@ -2887,9 +3116,6 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
includeUserIDs := strings.EqualFold(strings.TrimSpace(c.Query("includeUserIds")), "true")
membersByTenantID := make(map[string][]orgContextMember)
if includeUsers {
if h.UserRepo == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "user repository is not configured")
}
membersByTenantID, err = h.loadOrgContextMembers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug, includeUserIDs)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -2919,32 +3145,22 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
}
func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) (map[string][]orgContextMember, error) {
usersByID, err := h.UserRepo.FindByTenantIDs(ctx, tenantIDs)
if err != nil {
return nil, err
}
usersBySlug, err := h.UserRepo.FindByCompanyCodes(ctx, tenantSlugs)
if err != nil {
return nil, err
}
usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "")
identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
membersByTenantID := make(map[string][]orgContextMember)
users := append(usersByID, usersBySlug...)
users = append(users, usersByAppointment...)
for _, user := range users {
if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) {
for _, identity := range identities {
if seen[identity.ID] || !domain.IsOrgVisibleUserStatus(normalizeStatus(identity.State)) {
continue
}
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
assignments := mapOrgContextIdentityAssignments(identity, tenantByID, tenantBySlug, includeUserIDs)
if len(assignments) == 0 {
continue
}
seen[user.ID] = true
seen[identity.ID] = true
for _, assignment := range assignments {
membersByTenantID[assignment.TenantID] = append(membersByTenantID[assignment.TenantID], assignment.Member)
}
@@ -2952,6 +3168,55 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
return membersByTenantID, nil
}
func (h *TenantHandler) listOrgContextIdentities(ctx context.Context, tenantIDs, tenantSlugs []string) ([]service.KratosIdentity, error) {
allowedTenantKeys := make(map[string]bool, len(tenantIDs)+len(tenantSlugs))
for _, value := range append(tenantIDs, tenantSlugs...) {
key := strings.ToLower(strings.TrimSpace(value))
if key != "" {
allowedTenantKeys[key] = true
}
}
query := service.IdentityMirrorPageQuery{
Limit: 10000,
AllowedTenantKeys: allowedTenantKeys,
}
if h != nil && h.IdentityCache != nil {
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.orgContextIdentityMirrorReady(ctx) {
result, err := lister.ListIdentityMirrorPage(ctx, query)
if err != nil {
return nil, err
}
return result.Items, nil
}
}
if h == nil || h.KratosAdmin == nil {
return nil, errors.New("identity mirror is unavailable")
}
identities, err := h.KratosAdmin.ListIdentities(ctx)
if err != nil {
return nil, err
}
result, err := pageIdentityMirrorSlice(identities, query)
if err != nil {
return nil, err
}
return result.Items, nil
}
func (h *TenantHandler) orgContextIdentityMirrorReady(ctx context.Context) bool {
reader, ok := h.IdentityCache.(identityMirrorStatusReader)
if !ok {
return false
}
status, err := reader.GetIdentityCacheStatus(ctx)
if err != nil {
return false
}
return status.RedisReady &&
status.Status == "ready" &&
status.MirrorVersion == identityMirrorVersion
}
func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) {
normalized := strings.ToLower(strings.TrimSpace(slug))
for _, tenant := range tenants {
@@ -3086,8 +3351,55 @@ func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug m
return assignments
}
func mapOrgContextIdentityAssignments(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment {
assignments := make([]orgContextMemberAssignment, 0, 2)
seenTenants := map[string]bool{}
traits := identity.Traits
appointments := tenantClaimAppointmentsFromTraits(traits)
addTenant := func(tenant orgContextTenant, ok bool, appointment map[string]any) {
if !ok || seenTenants[tenant.ID] {
return
}
seenTenants[tenant.ID] = true
if appointment == nil {
appointment = lookupTenantClaimAppointment(appointments, tenant.ID, &domain.Tenant{
ID: tenant.ID,
Slug: tenant.Slug,
})
}
assignments = append(assignments, orgContextMemberAssignment{
TenantID: tenant.ID,
Member: mapOrgContextIdentityMember(identity, appointment, includeUserIDs),
})
}
for _, appointment := range appointments {
for _, key := range []string{"tenantId", "tenant_id"} {
if tenantID := tenantClaimString(appointment, key); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", appointment)
}
}
for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "", appointment)
}
}
}
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", nil)
}
if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "", nil)
}
return assignments
}
func mapOrgContextMember(user domain.User, appointment map[string]any, includeUserIDs bool) orgContextMember {
grade := user.Grade
grade := ""
position := user.Position
jobTitle := user.JobTitle
department := user.Department
@@ -3139,6 +3451,60 @@ func mapOrgContextMember(user domain.User, appointment map[string]any, includeUs
}
}
func mapOrgContextIdentityMember(identity service.KratosIdentity, appointment map[string]any, includeUserIDs bool) orgContextMember {
traits := identity.Traits
grade := ""
position := extractTraitString(traits, "position")
jobTitle := extractTraitString(traits, "jobTitle")
department := extractTraitString(traits, "department")
if value := tenantClaimString(appointment, "grade"); value != "" {
grade = value
}
if value := tenantClaimString(appointment, "position"); value != "" {
position = value
}
if value := tenantClaimString(appointment, "jobTitle"); value != "" {
jobTitle = value
}
if value := tenantClaimString(appointment, "job_title"); value != "" {
jobTitle = value
}
if value := tenantClaimString(appointment, "department"); value != "" {
department = value
}
isOwner := false
if value, ok := metadataBoolFromMap(appointment, "isOwner"); ok {
isOwner = value
}
isManager := false
if value, ok := metadataBoolFromMap(appointment, "isManager", "lead", "isLead"); ok {
isManager = value
}
isPrimary := false
if value, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok {
isPrimary = value
}
id := ""
phone := ""
if includeUserIDs {
id = identity.ID
phone = extractTraitString(traits, "phone_number")
}
return orgContextMember{
ID: id,
Email: extractTraitString(traits, "email"),
Name: extractTraitString(traits, "name"),
Phone: phone,
Department: department,
Grade: grade,
Position: position,
JobTitle: jobTitle,
IsOwner: isOwner,
IsManager: isManager,
IsPrimary: isPrimary,
}
}
func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant) *orgContextTreeNode {
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range tenants {
@@ -3170,6 +3536,24 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
}
func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
allTenants := tenants
if h.DB != nil {
var edges []domain.Tenant
if err := h.DB.WithContext(ctx).
Model(&domain.Tenant{}).
Select("id", "parent_id").
Find(&edges).Error; err == nil && len(edges) > 0 {
allTenants = edges
}
} else if h.Service != nil {
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
allTenants = listed
}
}
return h.countTenantMembersFromTenantSet(ctx, tenants, allTenants)
}
func (h *TenantHandler) countTenantMembersFromTenantSet(ctx context.Context, tenants []domain.Tenant, allTenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
@@ -3181,25 +3565,22 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
return counts, counts, nil
}
tenantIDs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
totalCounts := make(map[string]int64, len(tenants))
allTenantIDs := make([]string, 0, len(allTenants))
for _, tenant := range allTenants {
if strings.TrimSpace(tenant.ID) != "" {
tenantIDs = append(tenantIDs, tenant.ID)
allTenantIDs = append(allTenantIDs, tenant.ID)
}
}
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, allTenantIDs)
if err != nil {
return nil, nil, err
}
totalCounts := make(map[string]int64, len(tenants))
allTenants := tenants
if h.Service != nil {
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
allTenants = listed
}
for _, tenant := range tenants {
counts[tenant.ID] = directCounts[tenant.ID]
}
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range allTenants {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
@@ -3209,13 +3590,9 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
}
for _, tenant := range tenants {
descendantIDs := collectTenantSubtreeIDs(tenant.ID, childrenByParentID)
if len(descendantIDs) == 0 {
totalCounts[tenant.ID] = directCounts[tenant.ID]
continue
}
_, total, _, countErr := h.UserRepo.List(ctx, 0, 1, "", descendantIDs, "")
if countErr != nil {
return nil, nil, countErr
var total int64
for _, descendantID := range descendantIDs {
total += directCounts[descendantID]
}
totalCounts[tenant.ID] = total
}
@@ -3309,6 +3686,7 @@ func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
refreshRequested := parseBoolQuery(c.Query("refresh"))
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
role, userID, profileTenantID := orgChartProfileLogValues(profile)
slog.Info("orgchart snapshot request started",
@@ -3317,12 +3695,16 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"cache_mode", cacheMode,
"refresh", refreshRequested,
)
if cacheMode == "redis" && h.OrgChartCache != nil {
if cacheMode == "redis" && h.OrgChartCache != nil && !refreshRequested {
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
var cached orgChartSnapshotResponse
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
if strings.TrimSpace(cached.GeneratedAt) == "" {
cached.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
}
cached.Cache = orgChartSnapshotCacheInfo{
Source: "redis",
Hit: true,
@@ -3384,7 +3766,11 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
)
}
}
c.Set("X-Orgfront-Cache", "MISS")
if refreshRequested {
c.Set("X-Orgfront-Cache", "REFRESH")
} else {
c.Set("X-Orgfront-Cache", "MISS")
}
} else {
c.Set("X-Orgfront-Cache", "BYPASS")
}
@@ -3482,8 +3868,9 @@ func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *doma
}
return orgChartSnapshotResponse{
Tenants: tenantSummaries,
Users: users,
Tenants: tenantSummaries,
Users: users,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}, nil
}
@@ -3543,57 +3930,133 @@ func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profi
}
func (h *TenantHandler) listOrgChartUsers(ctx context.Context, profile *domain.UserProfileResponse, tenants []domain.Tenant) ([]userSummary, error) {
if h.UserRepo == nil {
return nil, errors.New("user repository is not configured")
}
role := ""
if profile != nil {
role = domain.NormalizeRole(profile.Role)
}
tenantIDs := []string{}
tenantSlugs := []string{}
if role != domain.RoleSuperAdmin {
tenantIDs = make([]string, 0, len(tenants))
tenantSlugs = make([]string, 0, len(tenants))
for _, tenant := range tenants {
tenantIDs = append(tenantIDs, tenant.ID)
tenantSlugs = append(tenantSlugs, tenant.Slug)
}
}
users, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", tenantIDs, "")
identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
if err != nil {
return nil, err
}
summaries := make([]userSummary, 0, len(users))
for _, user := range users {
summary := userSummary{
ID: user.ID,
Email: user.Email,
LoginID: user.Email,
Name: user.Name,
Phone: user.Phone,
Role: domain.NormalizeRole(user.Role),
Status: normalizeStatus(user.Status),
TenantSlug: userTenantSlug(user),
CompanyCode: userTenantSlug(user),
Metadata: user.Metadata,
Tenant: user.Tenant,
Department: user.Department,
Grade: user.Grade,
Position: user.Position,
JobTitle: user.JobTitle,
CreatedAt: formatTime(user.CreatedAt),
UpdatedAt: formatTime(user.UpdatedAt),
}
if h.Service != nil {
if joined, err := h.Service.ListJoinedTenants(ctx, user.ID); err == nil {
summary.JoinedTenants = joined
}
}
summaries = append(summaries, summary)
tenantByID := make(map[string]domain.Tenant, len(tenants))
tenantBySlug := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
tenantBySlug[strings.ToLower(tenant.Slug)] = tenant
}
summaries := make([]userSummary, 0, len(identities))
for _, identity := range identities {
summaries = append(summaries, mapOrgChartIdentitySummary(identity, tenantByID, tenantBySlug))
}
return summaries, nil
}
func mapOrgChartIdentitySummary(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]domain.Tenant) userSummary {
traits := identity.Traits
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := extractTraitString(traits, "tenantSlug")
var tenantSummary *domain.Tenant
if tenantID != "" {
if tenant, ok := tenantByID[tenantID]; ok {
tenantCopy := tenant
tenantSummary = &tenantCopy
if tenantSlug == "" {
tenantSlug = tenant.Slug
}
}
}
if tenantSummary == nil && tenantSlug != "" {
if tenant, ok := tenantBySlug[strings.ToLower(tenantSlug)]; ok {
tenantCopy := tenant
tenantSummary = &tenantCopy
if tenantID == "" {
tenantID = tenant.ID
}
}
}
metadata := make(domain.JSONMap)
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true, "tenantSlug": true,
"custom_login_ids": true, "id": true,
}
for key, value := range traits {
if !coreTraits[key] {
metadata[key] = value
}
}
return userSummary{
ID: identity.ID,
Email: extractTraitString(traits, "email"),
LoginID: resolvePasswordLoginID(traits),
Name: extractTraitString(traits, "name"),
Phone: extractTraitString(traits, "phone_number"),
Role: roleFromTraits(traits),
Status: normalizeStatus(identity.State),
TenantSlug: tenantSlug,
CompanyCode: tenantSlug,
Metadata: metadata,
Tenant: tenantSummary,
JoinedTenants: orgChartIdentityJoinedTenants(traits, tenantByID, tenantBySlug),
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
CreatedAt: formatTime(identity.CreatedAt),
UpdatedAt: formatTime(identity.UpdatedAt),
}
}
func orgChartIdentityJoinedTenants(traits map[string]any, tenantByID, tenantBySlug map[string]domain.Tenant) []domain.Tenant {
seen := make(map[string]bool)
joined := make([]domain.Tenant, 0, 2)
addTenant := func(tenant domain.Tenant, ok bool) {
if !ok || strings.TrimSpace(tenant.ID) == "" || seen[tenant.ID] {
return
}
seen[tenant.ID] = true
joined = append(joined, tenant)
}
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
}
if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "")
}
for _, appointment := range tenantClaimAppointmentsFromTraits(traits) {
for _, key := range []string{"tenantId", "tenant_id"} {
if tenantID := tenantClaimString(appointment, key); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
}
}
for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "")
}
}
}
return joined
}
func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader string) string {
role := "anonymous"
userID := "anonymous"
@@ -3651,6 +4114,15 @@ func orgChartSnapshotCacheExpiration() time.Duration {
return 0
}
func parseBoolQuery(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
token := c.Query("token")
if token == "" {
+451 -36
View File
@@ -106,6 +106,7 @@ func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainN
type MockUserRepoForHandler struct {
mock.Mock
deletedIDs []string
listCalls int
}
func (m *MockUserRepoForHandler) DB() *gorm.DB {
@@ -145,6 +146,7 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
}
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
m.listCalls += 1
for _, call := range m.ExpectedCalls {
if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, tenantIDs, cursor)
@@ -240,6 +242,53 @@ func toJSONString(t *testing.T, value any) string {
return string(raw)
}
func newReadyIdentityMirror(t *testing.T, now time.Time, identities ...service.KratosIdentity) *identityMirrorRedisStub {
t.Helper()
data := make(map[string]string, len(identities)+1)
for _, identity := range identities {
raw, err := json.Marshal(identity)
require.NoError(t, err)
data[identityMirrorKey(identity.ID)] = string(raw)
}
rawStatus, err := json.Marshal(domain.IdentityCacheStatus{
RedisReady: true,
Status: "ready",
ObservedCount: int64(len(identities)),
MirrorVersion: identityMirrorVersion,
LastRefreshedAt: &now,
})
require.NoError(t, err)
data["identity:mirror:state"] = string(rawStatus)
return &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: data}}
}
func orgContextIdentityFromUserFixture(user domain.User) service.KratosIdentity {
traits := map[string]any{
"email": user.Email,
"name": user.Name,
"phone_number": user.Phone,
"department": user.Department,
"position": user.Position,
"jobTitle": user.JobTitle,
}
if user.TenantID != nil {
traits["tenant_id"] = *user.TenantID
}
if strings.TrimSpace(user.CompanyCode) != "" {
traits["tenantSlug"] = strings.TrimSpace(user.CompanyCode)
}
for key, value := range user.Metadata {
traits[key] = value
}
return service.KratosIdentity{
ID: user.ID,
State: user.Status,
Traits: traits,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -268,6 +317,43 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"])
}
func TestTenantHandler_CreateTenantDoesNotAssignSuperAdminAsCreatorMember(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/tenants", h.CreateTenant)
input := map[string]any{
"name": "System Created Tenant",
"slug": "system-created-tenant",
}
body, _ := json.Marshal(input)
mockSvc.On(
"RegisterTenant",
mock.Anything,
"System Created Tenant",
"system-created-tenant",
domain.TenantTypeCompany,
"",
[]string(nil),
(*string)(nil),
"",
).Return(&domain.Tenant{ID: "system-created-id", Name: "System Created Tenant", Slug: "system-created-tenant"}, nil).Once()
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -293,8 +379,6 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
Return([]domain.User{}, int64(7), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -306,7 +390,60 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
require.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
assert.Equal(t, int64(2), res.Items[0].TotalMemberCount)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsPassesSearchToBackendQuery(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: "super_admin"})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{{ID: "tenant-1", Name: "Saman", Slug: "saman"}}
mockSvc.On("ListTenants", mock.Anything, 25, 0, "", "saman").Return(tenants, int64(1), nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"tenant-1"}).
Return(map[string]int64{"tenant-1": 1}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=25&search=saman", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_CountTenantMembersDoesNotListUsersPerTenant(t *testing.T) {
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
parentID := "parent-tenant"
childID := "child-tenant"
tenants := []domain.Tenant{
{ID: parentID, Name: "Parent", Slug: "parent"},
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
}
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
memberCounts, totalMemberCounts, err := h.countTenantMembers(context.Background(), tenants)
require.NoError(t, err)
require.Equal(t, int64(1), memberCounts[parentID])
require.Equal(t, int64(3), totalMemberCounts[parentID])
require.Equal(t, int64(2), memberCounts[childID])
require.Equal(t, int64(2), totalMemberCounts[childID])
require.Zero(t, mockUsers.listCalls)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
@@ -358,13 +495,9 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
return strings.HasPrefix(key, "orgchart:snapshot:")
}), mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Twice()
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID}).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return(users, int64(1), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
@@ -378,18 +511,68 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "MISS", resp.Header.Get("X-Orgfront-Cache"))
var body struct {
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
GeneratedAt string `json:"generatedAt"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Len(t, body.Tenants, 2)
require.Len(t, body.Users, 1)
require.NotEmpty(t, body.GeneratedAt)
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotRefreshBypassesRedisHitAndUpdatesCache(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
users := []domain.User{
{ID: "fresh-user", Email: "fresh@example.com", Name: "Fresh User", Role: domain.RoleUser, Status: "active", TenantID: &familyID, Tenant: &tenants[0], CreatedAt: now, UpdatedAt: now},
}
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.MatchedBy(func(raw string) bool {
return strings.Contains(raw, "fresh-user")
}), time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 1}, nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot?cache=redis&refresh=true", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "REFRESH", resp.Header.Get("X-Orgfront-Cache"))
var body struct {
Users []userSummary `json:"users"`
Cache orgChartSnapshotCacheInfo `json:"cache"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Len(t, body.Users, 1)
require.Equal(t, "fresh-user", body.Users[0].ID)
require.Equal(t, "database", body.Cache.Source)
require.False(t, body.Cache.Hit)
cache.AssertNotCalled(t, "Get", mock.Anything)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestOrgChartSnapshotCacheKeySharesSuperAdminGlobalSnapshot(t *testing.T) {
first := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
ID: "super-admin-1",
@@ -435,10 +618,8 @@ func TestTenantHandler_WarmOrgChartSnapshotCacheStoresSuperAdminGlobalSnapshot(t
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now)}
require.NoError(t, h.WarmOrgChartSnapshotCache(context.Background()))
raw := cache.values["orgchart:snapshot:v1:super_admin:all:none"]
@@ -469,10 +650,8 @@ func TestTenantHandler_RefreshOrgChartSnapshotCacheAfterTenantChangeInvalidatesA
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now)}
h.refreshOrgChartSnapshotCacheAfterTenantChange(context.Background(), "tenant_created")
@@ -515,11 +694,7 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID, teamID}).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{teamID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once()
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once()
h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
resp, err := app.Test(req, 1000)
@@ -538,6 +713,66 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotUsesIdentityMirrorWithoutLocalUsersDB(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
now := time.Date(2026, 6, 17, 13, 0, 0, 0, time.UTC)
parent := func(id string) *string { return &id }
familyID := "hanmac-family-id"
companyID := "hanmac-company-id"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, ParentID: parent(familyID), Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: companyID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
identity := service.KratosIdentity{
ID: "identity-orgchart-user",
State: domain.UserStatusActive,
Traits: map[string]any{
"email": "orgchart-mirror@example.com",
"name": "OrgChart Mirror",
"phone_number": "010-2222-3333",
"tenant_id": companyID,
"tenantSlug": "hanmac",
"position": "팀장",
"jobTitle": "Mirror Source",
},
CreatedAt: now,
UpdatedAt: now,
}
identityCache := newReadyIdentityMirror(t, now, identity)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, IdentityCache: identityCache}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, companyID}).Return(map[string]int64{familyID: 0, companyID: 1}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
Users []userSummary `json:"users"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Len(t, body.Users, 1)
require.Equal(t, "identity-orgchart-user", body.Users[0].ID)
require.Equal(t, "orgchart-mirror@example.com", body.Users[0].Email)
require.Equal(t, "hanmac", body.Users[0].TenantSlug)
require.Equal(t, "Mirror Source", body.Users[0].JobTitle)
require.Equal(t, 1, identityCache.pageCalls)
require.Equal(t, 0, identityCache.fullCalls)
mockUsers.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -566,10 +801,6 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{parentID, childID}, "").
Return([]domain.User{}, int64(3), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{childID}, "").
Return([]domain.User{}, int64(2), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -612,8 +843,6 @@ func TestTenantHandler_ListTenants(t *testing.T) {
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"t1", "t2"}).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t1"}, "").Return([]domain.User{}, int64(5), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t2"}, "").Return([]domain.User{}, int64(10), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -663,8 +892,6 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(3), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000001"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000002"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
resp, _ := app.Test(req)
@@ -1058,6 +1285,14 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
UpdatedAt: now,
},
}
identityFixtures := make([]service.KratosIdentity, 0, len(usersByTenantID)+len(usersBySlug)+len(usersByList))
for _, user := range usersByTenantID {
identityFixtures = append(identityFixtures, orgContextIdentityFromUserFixture(user))
}
for _, user := range usersByList {
identityFixtures = append(identityFixtures, orgContextIdentityFromUserFixture(user))
}
h.IdentityCache = newReadyIdentityMirror(t, now, identityFixtures...)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil)
@@ -1088,7 +1323,15 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
deptPlatform := tenantsPayload[2].(map[string]any)
platformMembers := deptPlatform["members"].([]any)
require.Len(t, platformMembers, 3)
firstUser := platformMembers[0].(map[string]any)
var firstUser map[string]any
for _, item := range platformMembers {
member := item.(map[string]any)
if member["email"] == "lead@example.com" {
firstUser = member
break
}
}
require.NotNil(t, firstUser)
require.NotContains(t, firstUser, "id")
require.NotContains(t, firstUser, "phone")
require.NotContains(t, firstUser, "tenantIds")
@@ -1132,6 +1375,83 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
require.NotContains(t, toJSONString(t, got), "extended-leave@example.com")
}
func TestTenantHandler_GetOrgContextJSONUsesIdentityMirrorWithoutLocalUsersDB(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
now := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
familyID := "group-hanmac-family"
companyID := "company-hanmac"
deptID := "dept-platform"
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: companyID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: deptID, Type: domain.TenantTypeUserGroup, ParentID: parent(companyID), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
identity := service.KratosIdentity{
ID: "identity-platform-lead",
State: domain.UserStatusActive,
Traits: map[string]any{
"email": "mirror-lead@example.com",
"name": "Mirror Lead",
"phone_number": "010-0000-0000",
"tenant_id": companyID,
"additionalAppointments": []any{
map[string]any{
"tenantId": deptID,
"tenantSlug": "platform",
"isPrimary": true,
"isOwner": true,
"position": "실장",
"jobTitle": "SSOT Lead",
},
},
},
CreatedAt: now,
UpdatedAt: now,
}
rawIdentity, err := json.Marshal(identity)
require.NoError(t, err)
rawStatus, err := json.Marshal(domain.IdentityCacheStatus{
RedisReady: true,
Status: "ready",
ObservedCount: 1,
MirrorVersion: identityMirrorVersion,
LastRefreshedAt: &now,
})
require.NoError(t, err)
identityCache := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(identity.ID): string(rawIdentity),
"identity:mirror:state": string(rawStatus),
}}}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, IdentityCache: identityCache}
app.Use(func(c *fiber.Ctx) error {
c.Locals("apiKeyName", "orgfront-ssot-client")
return c.Next()
})
app.Get("/org-context", h.GetOrgContext)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.Contains(t, toJSONString(t, got), "mirror-lead@example.com")
require.Contains(t, toJSONString(t, got), "SSOT Lead")
require.Equal(t, 1, identityCache.pageCalls)
require.Equal(t, 0, identityCache.fullCalls)
mockUsers.AssertNotCalled(t, "FindByTenantIDs", mock.Anything, mock.Anything)
mockUsers.AssertNotCalled(t, "FindByCompanyCodes", mock.Anything, mock.Anything)
mockUsers.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -1152,6 +1472,7 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
users := []domain.User{
{ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now},
}
h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
@@ -1182,6 +1503,63 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
require.NotContains(t, tree, "directUserIds")
}
func TestTenantHandler_GetOrgContextJSONDoesNotFallbackToUserGradeForTenantMember(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
app.Use(func(c *fiber.Ctx) error {
c.Locals("apiKeyName", "orgfront-ssot-client")
return c.Next()
})
app.Get("/org-context", h.GetOrgContext)
now := time.Date(2026, 6, 17, 9, 0, 0, 0, time.UTC)
tenantID := "company-hanmac"
tenants := []domain.Tenant{
{ID: tenantID, Type: domain.TenantTypeCompany, Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
users := []domain.User{
{
ID: "user-grade-only",
Email: "grade-only@example.com",
Name: "직급 단독",
Status: domain.UserStatusActive,
TenantID: &tenantID,
Grade: "책임",
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": tenantID,
"tenantSlug": "hanmac",
},
},
},
CreatedAt: now,
UpdatedAt: now,
},
}
h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{tenantID}).Return(users, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil)
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
tenantsPayload := got["tenants"].([]any)
members := tenantsPayload[0].(map[string]any)["members"].([]any)
require.Len(t, members, 1)
member := members[0].(map[string]any)
require.NotContains(t, member, "grade")
}
func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -1206,7 +1584,7 @@ func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil)
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil)
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac&includeUsers=false", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -1258,8 +1636,6 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testi
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Once()
mockUserRepo.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
Return([]domain.User{}, int64(152), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -1271,6 +1647,7 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testi
assert.Len(t, res.Items, 1)
assert.Equal(t, int64(152), res.Items[0].MemberCount)
assert.Equal(t, int64(152), res.Items[0].TotalMemberCount)
mockUserRepo.AssertExpectations(t)
}
@@ -1622,6 +1999,46 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ImportTenantsCSVDoesNotAssignSuperAdminAsCompanyMember(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/tenants/import", h.ImportTenantsCSV)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "tenants.csv")
assert.NoError(t, err)
_, err = part.Write([]byte("name,type,parent_tenant_id,slug,memo,email_domain\nImported Company,COMPANY,,imported-company,,\n"))
assert.NoError(t, err)
assert.NoError(t, writer.Close())
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
mockSvc.On(
"RegisterTenant",
mock.Anything,
"Imported Company",
"imported-company",
domain.TenantTypeCompany,
"",
[]string{},
(*string)(nil),
"",
).Return(&domain.Tenant{ID: "imported-company-id", Name: "Imported Company", Slug: "imported-company"}, nil).Once()
req := httptest.NewRequest("POST", "/tenants/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
mockSvc.AssertExpectations(t)
}
func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization"))
}
@@ -1801,9 +2218,6 @@ func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T)
mockSvc.On("ApproveTenant", mock.Anything, tenantID).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, tenantID}).Return(map[string]int64{familyID: 0, tenantID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(1), nil).Once()
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
@@ -1811,6 +2225,7 @@ func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T)
Service: mockSvc,
UserRepo: mockUsers,
OrgChartCache: cache,
IdentityCache: newReadyIdentityMirror(t, now),
}
app.Post("/tenants/:id/approve", h.ApproveTenant)
+309 -69
View File
@@ -95,11 +95,41 @@ func sanitizeUserMetadata(metadata map[string]any) map[string]any {
if key == "hanmacFamily" || key == "userType" {
continue
}
if key == "additionalAppointments" {
sanitized[key] = normalizeUserAppointmentGrades(value)
continue
}
sanitized[key] = value
}
return sanitized
}
func normalizeUserAppointmentGrades(raw any) []any {
appointments := userAppointmentSliceFromRaw(raw)
for i, item := range appointments {
appointment, ok := item.(map[string]any)
if !ok {
continue
}
if grade, ok := appointment["grade"].(string); ok {
appointment["grade"] = normalizeInternalGradeName(grade)
}
appointments[i] = appointment
}
return appointments
}
func normalizeInternalGradeName(grade string) string {
switch strings.ReplaceAll(strings.TrimSpace(grade), " ", "") {
case "상무":
return "상무이사"
case "전무":
return "전무이사"
default:
return strings.TrimSpace(grade)
}
}
func userAppointmentSliceFromRaw(raw any) []any {
switch values := raw.(type) {
case []any:
@@ -142,6 +172,144 @@ func userAppointmentTenantKey(raw any) string {
return ""
}
func userAppointmentMatchesTenant(appointment map[string]any, tenantID string, tenantSlug string) bool {
targetID := strings.ToLower(strings.TrimSpace(tenantID))
targetSlug := strings.ToLower(strings.TrimSpace(tenantSlug))
appointmentID := strings.ToLower(normalizeMetadataString(appointment["tenantId"]))
appointmentSlug := strings.ToLower(normalizeMetadataString(appointment["tenantSlug"]))
if appointmentSlug == "" {
appointmentSlug = strings.ToLower(normalizeMetadataString(appointment["slug"]))
}
return (targetID != "" && appointmentID == targetID) ||
(targetSlug != "" && appointmentSlug == targetSlug)
}
func tenantBoundGradeFromTraits(traits map[string]any) string {
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := extractTraitString(traits, "tenantSlug")
appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
if len(appointments) == 0 {
if metadata, ok := traits["metadata"].(map[string]any); ok {
appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
}
}
for _, raw := range appointments {
appointment, ok := raw.(map[string]any)
if !ok || !userAppointmentMatchesTenant(appointment, tenantID, tenantSlug) {
continue
}
if grade := normalizeMetadataString(appointment["grade"]); grade != "" {
return grade
}
}
for _, raw := range appointments {
appointment, ok := raw.(map[string]any)
if !ok {
continue
}
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary", "representative", "isRepresentative"); !ok || !isPrimary {
continue
}
if grade := normalizeMetadataString(appointment["grade"]); grade != "" {
return grade
}
}
return ""
}
func tenantBoundGradeFromUser(user domain.User) string {
if user.Metadata == nil {
return ""
}
traits := map[string]any{
"tenant_id": userTenantIDValue(user),
}
for key, value := range user.Metadata {
traits[key] = value
}
return tenantBoundGradeFromTraits(traits)
}
func userTenantIDValue(user domain.User) string {
if user.TenantID == nil {
return ""
}
return strings.TrimSpace(*user.TenantID)
}
func applyTenantBoundGrade(metadata map[string]any, tenant *domain.Tenant, grade string) map[string]any {
if metadata == nil {
metadata = map[string]any{}
}
if tenant == nil || strings.TrimSpace(tenant.ID) == "" {
return metadata
}
normalizedGrade := normalizeInternalGradeName(grade)
appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
matched := false
for i, raw := range appointments {
appointment, ok := raw.(map[string]any)
if !ok || !userAppointmentMatchesTenant(appointment, tenant.ID, tenant.Slug) {
continue
}
appointment["tenantId"] = tenant.ID
appointment["tenantSlug"] = tenant.Slug
appointment["tenantName"] = tenant.Name
if normalizedGrade == "" {
delete(appointment, "grade")
} else {
appointment["grade"] = normalizedGrade
}
appointments[i] = appointment
matched = true
}
if !matched && normalizedGrade != "" {
appointments = append(appointments, map[string]any{
"tenantId": tenant.ID,
"tenantSlug": tenant.Slug,
"tenantName": tenant.Name,
"grade": normalizedGrade,
})
}
if len(appointments) > 0 {
metadata["additionalAppointments"] = appointments
}
return metadata
}
func applyTenantBoundGradeToTraits(ctx context.Context, tenantService service.TenantService, traits map[string]any, grade string) {
delete(traits, "grade")
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := extractTraitString(traits, "tenantSlug")
var tenant *domain.Tenant
if tenantService != nil {
if tenantID != "" {
if found, err := tenantService.GetTenant(ctx, tenantID); err == nil {
tenant = found
}
}
if tenant == nil && tenantSlug != "" {
if found, err := tenantService.GetTenantBySlug(ctx, tenantSlug); err == nil {
tenant = found
}
}
}
if tenant == nil {
tenant = &domain.Tenant{ID: tenantID, Slug: tenantSlug}
}
if strings.TrimSpace(tenant.ID) == "" && strings.TrimSpace(tenant.Slug) == "" {
return
}
metadata := map[string]any{}
if appointments, ok := traits["additionalAppointments"]; ok {
metadata["additionalAppointments"] = appointments
}
metadata = applyTenantBoundGrade(metadata, tenant, grade)
if appointments, ok := metadata["additionalAppointments"]; ok {
traits["additionalAppointments"] = appointments
}
}
func mergeUserAddTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
if tenant == nil {
return metadata
@@ -507,14 +675,7 @@ func normalizeAssignableSystemRole(value string) (string, bool) {
}
func gradeFromTraits(traits map[string]any) string {
value := strings.TrimSpace(extractTraitString(traits, "grade"))
if value == "" {
return ""
}
if _, ok := domain.NormalizeRoleAlias(value); ok {
return ""
}
return value
return tenantBoundGradeFromTraits(traits)
}
func rejectLegacyCompanyCode(value string) error {
@@ -631,6 +792,14 @@ type identityMirrorLister interface {
ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error)
}
type identityMirrorPageLister interface {
ListIdentityMirrorPage(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error)
}
type identityMirrorStore interface {
StoreIdentityMirror(ctx context.Context, identity service.KratosIdentity) error
}
type identityMirrorStatusReader interface {
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
}
@@ -856,68 +1025,29 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
})
}
identities, err := h.listIdentitiesFromMirrorOrKratos(c.Context())
allowedTenantKeys := map[string]bool(nil)
if requesterRole != domain.RoleSuperAdmin {
allowedTenantKeys = manageableSlugs
}
page, err := h.listIdentityMirrorPageOrKratos(c.Context(), service.IdentityMirrorPageQuery{
Limit: limit,
Offset: offset,
Cursor: cursorRaw,
Search: search,
TenantSlug: tenantSlug,
TenantID: targetTenantID,
AllowedTenantKeys: allowedTenantKeys,
})
if err != nil {
slog.Warn("Identity mirror unavailable for user list", "error", err)
return errorJSON(c, fiber.StatusServiceUnavailable, "identity mirror unavailable")
}
filtered := make([]service.KratosIdentity, 0, len(identities))
searchLower := strings.ToLower(search)
for _, identity := range identities {
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
// Tenant Admin & Member filtering
if requesterRole != domain.RoleSuperAdmin {
hasAccess := anyTenantKeyAllowed(tenantAccessKeys, manageableSlugs)
if !hasAccess {
continue
}
}
// Dedicated tenantSlug filter
if tenantSlug != "" {
targetKeys := map[string]bool{
targetTenantID: true,
strings.ToLower(tenantSlug): true,
}
matches := anyTenantKeyAllowed(tenantAccessKeys, targetKeys)
if !matches {
continue
}
}
if !identityMatchesSearch(identity, searchLower) {
continue
}
filtered = append(filtered, identity)
}
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
total := int64(len(filtered))
nextCursor := ""
var pageIdentities []service.KratosIdentity
if cursorRaw != "" {
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else {
if offset > len(filtered) {
offset = len(filtered)
}
end := min(offset+limit, len(filtered))
pageIdentities = filtered[offset:end]
if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
}
items := make([]userSummary, 0, len(pageIdentities))
for _, identity := range pageIdentities {
items := make([]userSummary, 0, len(page.Items))
for _, identity := range page.Items {
summary := h.mapIdentitySummary(c.Context(), identity)
items = append(items, summary)
}
@@ -926,9 +1056,9 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
Items: items,
Limit: limit,
Offset: offset,
Total: total,
Total: page.Total,
Cursor: cursorRaw,
NextCursor: nextCursor,
NextCursor: page.NextCursor,
})
}
@@ -1024,6 +1154,84 @@ func (h *UserHandler) listIdentitiesFromMirrorOrKratos(ctx context.Context) ([]s
return h.rebuildIdentityMirror(ctx)
}
func (h *UserHandler) listIdentityMirrorPageOrKratos(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
if h != nil && h.IdentityCache != nil {
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.identityMirrorStatusReady(ctx) {
return lister.ListIdentityMirrorPage(ctx, query)
}
}
identities, err := h.rebuildIdentityMirror(ctx)
if err != nil {
return service.IdentityMirrorPageResult{}, err
}
if h != nil && h.IdentityCache != nil {
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.identityMirrorStatusReady(ctx) {
return lister.ListIdentityMirrorPage(ctx, query)
}
}
return pageIdentityMirrorSlice(identities, query)
}
func pageIdentityMirrorSlice(identities []service.KratosIdentity, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
if query.Limit <= 0 {
query.Limit = 50
}
if query.Offset < 0 {
query.Offset = 0
}
searchLower := strings.ToLower(strings.TrimSpace(query.Search))
targetKeys := make(map[string]bool)
for _, value := range []string{query.TenantID, query.TenantSlug} {
key := strings.ToLower(strings.TrimSpace(value))
if key != "" {
targetKeys[key] = true
}
}
filtered := make([]service.KratosIdentity, 0, len(identities))
for _, identity := range identities {
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
if len(query.AllowedTenantKeys) > 0 && !anyTenantKeyAllowed(tenantAccessKeys, query.AllowedTenantKeys) {
continue
}
if len(targetKeys) > 0 && !anyTenantKeyAllowed(tenantAccessKeys, targetKeys) {
continue
}
if !identityMatchesSearch(identity, searchLower) {
continue
}
filtered = append(filtered, identity)
}
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
total := int64(len(filtered))
nextCursor := ""
var pageIdentities []service.KratosIdentity
if strings.TrimSpace(query.Cursor) != "" {
var err error
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, query.Limit, query.Cursor, kratosIdentityCursorKey)
if err != nil {
return service.IdentityMirrorPageResult{}, err
}
} else {
if query.Offset > len(filtered) {
query.Offset = len(filtered)
}
end := min(query.Offset+query.Limit, len(filtered))
pageIdentities = filtered[query.Offset:end]
if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
}
return service.IdentityMirrorPageResult{
Items: pageIdentities,
Total: total,
Cursor: query.Cursor,
NextCursor: nextCursor,
}, nil
}
func (h *UserHandler) WarmIdentityMirror(ctx context.Context) (int, error) {
identities, err := h.rebuildIdentityMirror(ctx)
if err != nil {
@@ -1066,6 +1274,24 @@ func (h *UserHandler) identityMirrorReady(ctx context.Context, identityCount int
status.ObservedCount == int64(identityCount)
}
func (h *UserHandler) identityMirrorStatusReady(ctx context.Context) bool {
if h == nil || h.IdentityCache == nil {
return false
}
reader, ok := h.IdentityCache.(identityMirrorStatusReader)
if !ok {
return false
}
status, err := reader.GetIdentityCacheStatus(ctx)
if err != nil {
return false
}
return status.RedisReady &&
status.Status == "ready" &&
status.MirrorVersion == identityMirrorVersion &&
status.ObservedCount > 0
}
func (h *UserHandler) flushIdentityMirror(ctx context.Context) {
if h == nil || h.IdentityCache == nil {
return
@@ -1108,6 +1334,10 @@ func (h *UserHandler) storeIdentityMirror(identity service.KratosIdentity) {
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
return
}
if store, ok := h.IdentityCache.(identityMirrorStore); ok {
_ = store.StoreIdentityMirror(context.Background(), identity)
return
}
raw, err := json.Marshal(identity)
if err != nil {
return
@@ -1235,7 +1465,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
attributes := map[string]any{
"department": req.Department,
"grade": strings.TrimSpace(req.Grade),
"position": req.Position,
"jobTitle": req.JobTitle,
"affiliationType": "internal",
@@ -1289,6 +1518,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
tenantID = tenant.ID
req.CompanyCode = tenant.Slug
resolvedTenant = tenant
}
attributes["role"] = role
@@ -1308,6 +1538,10 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
}
if strings.TrimSpace(req.Grade) != "" {
req.Metadata = applyTenantBoundGrade(req.Metadata, resolvedTenant, req.Grade)
}
// Merge custom metadata into attributes
for k, v := range req.Metadata {
// Don't overwrite core fields
@@ -1789,6 +2023,13 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
item.Metadata["additionalAppointments"] = resolvedAppointments
}
if strings.TrimSpace(item.Grade) != "" {
item.Metadata = applyTenantBoundGrade(item.Metadata, &domain.Tenant{
ID: tItem.ID,
Slug: tItem.Slug,
Name: tItem.Name,
}, item.Grade)
}
normalizeBulkUserAliasMetadata(item.Metadata)
item.Metadata = sanitizeUserMetadata(item.Metadata)
@@ -1800,7 +2041,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
attributes := map[string]any{
"department": dept,
"grade": strings.TrimSpace(item.Grade),
"position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal",
@@ -2350,7 +2590,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
traits["department"] = *req.Department
}
if req.Grade != nil {
traits["grade"] = *req.Grade
applyTenantBoundGradeToTraits(c.Context(), h.TenantService, traits, *req.Grade)
}
if req.Position != nil {
traits["position"] = *req.Position
@@ -2783,7 +3023,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["department"] = strings.TrimSpace(*req.Department)
}
if req.Grade != nil {
traits["grade"] = strings.TrimSpace(*req.Grade)
applyTenantBoundGradeToTraits(c.Context(), h.TenantService, traits, *req.Grade)
}
if req.Position != nil {
traits["position"] = strings.TrimSpace(*req.Position)
@@ -3344,7 +3584,7 @@ func (h *UserHandler) mapLocalUserSummary(ctx context.Context, user domain.User)
TenantSlug: tenantSlug,
CompanyCode: tenantSlug,
Department: user.Department,
Grade: user.Grade,
Grade: tenantBoundGradeFromUser(user),
Position: user.Position,
JobTitle: user.JobTitle,
Metadata: user.Metadata,
+129 -12
View File
@@ -993,6 +993,22 @@ func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T)
mockOry.AssertExpectations(t)
}
func TestApplyTenantBoundGradeNormalizesDirectorLevelNames(t *testing.T) {
tenant := &domain.Tenant{ID: "tenant-1", Slug: "tenant", Name: "Tenant"}
metadata := applyTenantBoundGrade(nil, tenant, "상무")
appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
require.Len(t, appointments, 1)
appointment := appointments[0].(map[string]any)
require.Equal(t, "상무이사", appointment["grade"])
metadata = applyTenantBoundGrade(metadata, tenant, "전무")
appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
require.Len(t, appointments, 1)
appointment = appointments[0].(map[string]any)
require.Equal(t, "전무이사", appointment["grade"])
}
func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -1098,9 +1114,17 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
type identityMirrorRedisStub struct {
mockRedisRepo
pageCalls int
fullCalls int
failFull bool
lastQuery service.IdentityMirrorPageQuery
}
func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) {
s.fullCalls++
if s.failFull {
return nil, errors.New("full identity mirror materialization is forbidden")
}
identities := make([]service.KratosIdentity, 0, len(s.data))
for key, raw := range s.data {
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
@@ -1118,6 +1142,35 @@ func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]se
return identities, nil
}
func (s *identityMirrorRedisStub) ListIdentityMirrorPage(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
s.pageCalls++
s.lastQuery = query
identities := make([]service.KratosIdentity, 0, len(s.data))
for key, raw := range s.data {
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
continue
}
var identity service.KratosIdentity
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
continue
}
if strings.TrimSpace(identity.ID) == "" {
continue
}
identities = append(identities, identity)
}
return pageIdentityMirrorSlice(identities, query)
}
func (s *identityMirrorRedisStub) StoreIdentityMirror(ctx context.Context, identity service.KratosIdentity) error {
raw, err := json.Marshal(identity)
if err != nil {
return err
}
s.data[identityMirrorKey(identity.ID)] = string(raw)
return nil
}
func (s *identityMirrorRedisStub) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
raw := s.data["identity:mirror:state"]
if strings.TrimSpace(raw) == "" {
@@ -1174,7 +1227,7 @@ func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: mockRepo,
IdentityCache: &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
IdentityCache: &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(mirrorIdentity.ID): string(rawMirrorIdentity),
"identity:mirror:state": string(rawState),
}}},
@@ -1200,14 +1253,71 @@ func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing
require.Len(t, res.Items, 1)
require.Equal(t, "mirror-user-1", res.Items[0].ID)
require.Equal(t, "mirror1@example.com", res.Items[0].Email)
cache := h.IdentityCache.(*identityMirrorRedisStub)
require.Equal(t, 1, cache.pageCalls)
require.Equal(t, 0, cache.fullCalls)
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
}
func TestUserHandler_ListUsersPassesQueryToIdentityMirrorPageInsteadOfLoadingFullMirror(t *testing.T) {
app := fiber.New()
createdAt := time.Date(2026, 6, 17, 8, 50, 0, 0, time.UTC)
identities := []service.KratosIdentity{
{ID: "user-new", State: "active", CreatedAt: createdAt.Add(2 * time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "new@example.com", "name": "New User"}},
{ID: "user-needle", State: "active", CreatedAt: createdAt.Add(time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "needle@example.com", "name": "Needle User"}},
{ID: "user-old", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "old@example.com", "name": "Old User"}},
}
data := map[string]string{}
for _, identity := range identities {
raw, err := json.Marshal(identity)
require.NoError(t, err)
data[identityMirrorKey(identity.ID)] = string(raw)
}
state := domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
MirrorVersion: identityMirrorVersion,
ObservedCount: int64(len(identities)),
}
rawState, err := json.Marshal(state)
require.NoError(t, err)
data["identity:mirror:state"] = string(rawState)
cache := &identityMirrorRedisStub{
mockRedisRepo: mockRedisRepo{data: data},
failFull: true,
}
h := &UserHandler{
KratosAdmin: new(MockKratosAdmin),
IdentityCache: cache,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/users", h.ListUsers)
req := httptest.NewRequest("GET", "/users?limit=1&search=needle", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res userListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Len(t, res.Items, 1)
require.Equal(t, "user-needle", res.Items[0].ID)
require.Equal(t, 1, cache.pageCalls)
require.Equal(t, 0, cache.fullCalls)
require.Equal(t, 1, cache.lastQuery.Limit)
require.Equal(t, "needle", cache.lastQuery.Search)
}
func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{}}}
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{}}}
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
h := &UserHandler{
@@ -1330,7 +1440,7 @@ func TestUserHandler_ListUsersTenantSlugFilterIncludesAdditionalAppointments(t *
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
mockKratos := new(MockKratosAdmin)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey("stale-user"): `{"id":"stale-user"}`,
}}}
createdAt := time.Date(2026, 6, 12, 18, 30, 0, 0, time.UTC)
@@ -1382,7 +1492,7 @@ func TestUserHandler_ListUsersRebuildsLegacyReadyMirrorWithoutVersion(t *testing
}
rawLegacyState, err := json.Marshal(legacyState)
require.NoError(t, err)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(legacyIdentity.ID): string(rawLegacyIdentity),
"identity:mirror:state": string(rawLegacyState),
}}}
@@ -1436,7 +1546,7 @@ func TestUserHandler_ListUsersRebuildsPartialMirrorFromKratos(t *testing.T) {
}
rawPartialIdentity, err := json.Marshal(partialIdentity)
require.NoError(t, err)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(partialIdentity.ID): string(rawPartialIdentity),
}}}
kratosIdentities := []service.KratosIdentity{
@@ -3946,18 +4056,24 @@ func TestUserHandler_BulkUpdateUsersRejectsInternalDomainMoveToPersonalTenant(t
mockTenant.AssertExpectations(t)
}
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
func TestUserHandler_MapToLocalUserPreservesTenantBoundGradeForCompatibility(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{
ID: "user-grade-id",
State: "active",
Traits: map[string]any{
"email": "grade@example.com",
"name": "Grade User",
"role": domain.RoleUser,
"grade": "수석",
"position": "팀장",
"companyCode": "hanmac",
"email": "grade@example.com",
"name": "Grade User",
"role": domain.RoleUser,
"grade": "수석",
"position": "팀장",
"tenant_id": "tenant-1",
"additionalAppointments": []any{
map[string]any{
"tenantId": "tenant-1",
"grade": "수석",
},
},
},
}
@@ -3967,6 +4083,7 @@ func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
assert.Equal(t, "수석", localUser.Grade)
assert.Equal(t, "팀장", localUser.Position)
assert.NotContains(t, localUser.Metadata, "grade")
assert.Contains(t, localUser.Metadata, "additionalAppointments")
}
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
+76 -4
View File
@@ -69,6 +69,26 @@ func (r *userRepository) withTenantMembershipFilter(db *gorm.DB, tenantIDs []str
clauses = append(clauses, "metadata @> ?::jsonb")
args = append(args, string(payload))
}
clauses = append(clauses, `EXISTS (
SELECT 1
FROM tenants AS membership_tenants
WHERE membership_tenants.id IN ?
AND users.metadata @> jsonb_build_object(
'additionalAppointments',
jsonb_build_array(jsonb_build_object('tenantSlug', membership_tenants.slug))
)
)`)
args = append(args, tenantIDs)
clauses = append(clauses, `EXISTS (
SELECT 1
FROM tenants AS membership_tenants
WHERE membership_tenants.id IN ?
AND users.metadata @> jsonb_build_object(
'additionalAppointments',
jsonb_build_array(jsonb_build_object('tenant_slug', membership_tenants.slug))
)
)`)
args = append(args, tenantIDs)
return db.Where("("+strings.Join(clauses, " OR ")+")", args...)
}
@@ -170,11 +190,63 @@ func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []strin
}
for _, tenantID := range tenantIDs {
var count int64
if err := r.withTenantMembershipFilter(r.db.WithContext(ctx).Model(&domain.User{}), []string{tenantID}).Count(&count).Error; err != nil {
return nil, err
tenantID = strings.TrimSpace(tenantID)
if tenantID != "" {
counts[tenantID] = 0
}
counts[tenantID] = count
}
type result struct {
TenantID string
Count int64
}
var results []result
if err := r.db.WithContext(ctx).Raw(`
WITH requested_tenants AS (
SELECT id, slug
FROM tenants
WHERE id IN ?
),
memberships AS (
SELECT users.id AS user_id, users.tenant_id::text AS tenant_id
FROM users
WHERE users.deleted_at IS NULL
AND users.tenant_id IN ?
UNION ALL
SELECT users.id AS user_id, appointment ->> 'tenantId' AS tenant_id
FROM users
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(users.metadata -> 'additionalAppointments', '[]'::jsonb)) AS appointment
WHERE users.deleted_at IS NULL
AND appointment ->> 'tenantId' IN ?
UNION ALL
SELECT users.id AS user_id, requested_tenants.id::text AS tenant_id
FROM users
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(users.metadata -> 'additionalAppointments', '[]'::jsonb)) AS appointment
JOIN requested_tenants ON LOWER(requested_tenants.slug) = LOWER(appointment ->> 'tenantSlug')
WHERE users.deleted_at IS NULL
UNION ALL
SELECT users.id AS user_id, requested_tenants.id::text AS tenant_id
FROM users
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(users.metadata -> 'additionalAppointments', '[]'::jsonb)) AS appointment
JOIN requested_tenants ON LOWER(requested_tenants.slug) = LOWER(appointment ->> 'tenant_slug')
WHERE users.deleted_at IS NULL
)
SELECT tenant_id, COUNT(DISTINCT user_id) AS count
FROM memberships
WHERE tenant_id IN ?
GROUP BY tenant_id
`, tenantIDs, tenantIDs, tenantIDs, tenantIDs).Scan(&results).Error; err != nil {
return nil, err
}
for _, result := range results {
counts[result.TenantID] = result.Count
}
return counts, nil
}
@@ -54,6 +54,36 @@ func TestUserRepository(t *testing.T) {
assert.Equal(t, "010-1234-5678", found.Phone)
})
t.Run("Create and Update preserve top-level user grade for compatibility", func(t *testing.T) {
testDB.Exec("DELETE FROM user_login_ids")
testDB.Exec("DELETE FROM users WHERE email IN ?", []string{"grade-create@example.com", "grade-update@example.com"})
created := &domain.User{
Email: "grade-create@example.com",
Name: "Grade Create",
Role: domain.RoleUser,
Grade: "책임",
}
require.NoError(t, repo.Create(ctx, created))
found, err := repo.FindByEmail(ctx, created.Email)
require.NoError(t, err)
require.Equal(t, "책임", found.Grade)
updated := &domain.User{
ID: uuid.NewString(),
Email: "grade-update@example.com",
Name: "Grade Update",
Role: domain.RoleUser,
Grade: "수석",
}
require.NoError(t, repo.Update(ctx, updated))
found, err = repo.FindByEmail(ctx, updated.Email)
require.NoError(t, err)
require.Equal(t, "수석", found.Grade)
})
t.Run("Update preserves archived email reservation", func(t *testing.T) {
testDB.Exec("DELETE FROM user_login_ids")
testDB.Exec("DELETE FROM users")
@@ -273,6 +303,45 @@ func TestUserRepository_ListIncludesAdditionalTenantAppointments(t *testing.T) {
assert.Equal(t, int64(2), counts[additionalTenant.ID])
}
func TestUserRepository_ListIncludesAdditionalTenantAppointmentsBySlug(t *testing.T) {
repo := NewUserRepository(testDB)
ctx := context.Background()
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
primaryTenant := createUserRepositoryTestTenant(t, "repo-private-primary-tenant")
visibleTenant := createUserRepositoryTestTenant(t, "repo-visible-leader-tenant")
primaryTenantID := primaryTenant.ID
user := domain.User{
ID: uuid.NewString(),
Email: "slug-appointment-leader@example.com",
Name: "Slug Appointment Leader",
Role: domain.RoleUser,
TenantID: &primaryTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantSlug": visibleTenant.Slug,
"tenantName": visibleTenant.Name,
"isOwner": true,
},
},
},
}
require.NoError(t, repo.Create(ctx, &user))
listed, total, _, err := repo.List(ctx, 0, 20, "", []string{visibleTenant.ID}, "")
require.NoError(t, err)
require.Equal(t, int64(1), total)
require.Len(t, listed, 1)
assert.Equal(t, "slug-appointment-leader@example.com", listed[0].Email)
counts, err := repo.CountByTenantIDs(ctx, []string{visibleTenant.ID})
require.NoError(t, err)
assert.Equal(t, int64(1), counts[visibleTenant.ID])
}
func createUserRepositoryTestTenant(t *testing.T, slug string) domain.Tenant {
t.Helper()
require.NoError(t, testDB.Unscoped().Where("slug = ?", slug).Delete(&domain.Tenant{}).Error)
+285
View File
@@ -2,9 +2,12 @@ package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/pagination"
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
@@ -21,10 +24,28 @@ type identityMirrorStateStore struct {
Status string `json:"status"`
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
LastError string `json:"lastError,omitempty"`
MirrorVersion string `json:"mirrorVersion,omitempty"`
ObservedCount int64 `json:"observedCount,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
type IdentityMirrorPageQuery struct {
Limit int
Offset int
Cursor string
Search string
TenantID string
TenantSlug string
AllowedTenantKeys map[string]bool
}
type IdentityMirrorPageResult struct {
Items []KratosIdentity
Total int64
Cursor string
NextCursor string
}
// NewRedisService creates and returns a new RedisService
func NewRedisService() (*RedisService, error) {
redisAddr := os.Getenv("REDIS_ADDR")
@@ -199,6 +220,7 @@ func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.Ident
return domain.IdentityCacheStatus{
Status: status,
RedisReady: true,
MirrorVersion: stored.MirrorVersion,
ObservedCount: stored.ObservedCount,
KeyCount: keyCount,
LastRefreshedAt: stored.LastRefreshedAt,
@@ -271,6 +293,269 @@ func (s *RedisService) ListIdentityMirrors(ctx context.Context) ([]KratosIdentit
return identities, nil
}
func (s *RedisService) StoreIdentityMirror(ctx context.Context, identity KratosIdentity) error {
if s == nil || s.Client == nil {
return os.ErrInvalid
}
identityID := strings.TrimSpace(identity.ID)
if identityID == "" {
return os.ErrInvalid
}
raw, err := json.Marshal(identity)
if err != nil {
return err
}
if err := s.Client.Set(ctx, "identity:mirror:"+identityID, string(raw), 0).Err(); err != nil {
return err
}
score := float64(identityMirrorScoreTime(identity).UnixMilli())
if err := s.Client.ZAdd(ctx, "identity:index:created_at", &redis.Z{
Score: score,
Member: identityID,
}).Err(); err != nil {
return err
}
for _, tenantKey := range identityMirrorTenantKeys(identity.Traits) {
if err := s.Client.SAdd(ctx, "identity:index:tenant:"+tenantKey, identityID).Err(); err != nil {
return err
}
}
return nil
}
func (s *RedisService) ListIdentityMirrorPage(ctx context.Context, query IdentityMirrorPageQuery) (IdentityMirrorPageResult, error) {
if s == nil || s.Client == nil {
return IdentityMirrorPageResult{}, os.ErrInvalid
}
if query.Limit <= 0 {
query.Limit = 50
}
if query.Offset < 0 {
query.Offset = 0
}
cursor, err := pagination.Decode(query.Cursor)
if err != nil {
return IdentityMirrorPageResult{}, err
}
search := strings.ToLower(strings.TrimSpace(query.Search))
targetTenantKeys := identityMirrorTargetTenantKeys(query)
maxScore := "+inf"
if cursor != nil {
maxScore = strconv.FormatInt(cursor.Timestamp.UnixMilli(), 10)
}
const batchSize int64 = 250
var offset int64
var total int64
matched := make([]KratosIdentity, 0, query.Limit+1)
pageStart := query.Offset
if cursor != nil {
pageStart = 0
}
for {
zItems, err := s.Client.ZRevRangeByScoreWithScores(ctx, "identity:index:created_at", &redis.ZRangeBy{
Max: maxScore,
Min: "-inf",
Offset: offset,
Count: batchSize,
}).Result()
if err != nil {
return IdentityMirrorPageResult{}, err
}
if len(zItems) == 0 {
break
}
keys := make([]string, 0, len(zItems))
for _, item := range zItems {
id, ok := item.Member.(string)
if !ok || strings.TrimSpace(id) == "" {
continue
}
keys = append(keys, "identity:mirror:"+id)
}
rawItems, err := s.Client.MGet(ctx, keys...).Result()
if err != nil {
return IdentityMirrorPageResult{}, err
}
for _, raw := range rawItems {
rawString, ok := raw.(string)
if !ok || strings.TrimSpace(rawString) == "" {
continue
}
var identity KratosIdentity
if err := json.Unmarshal([]byte(rawString), &identity); err != nil {
continue
}
if strings.TrimSpace(identity.ID) == "" {
continue
}
if cursor != nil {
timestamp, id := identityMirrorCursorKey(identity)
if !pagination.ComesAfter(timestamp, id, cursor) {
continue
}
}
if !identityMirrorMatchesTenantScope(identity, targetTenantKeys, query.AllowedTenantKeys) {
continue
}
if !identityMirrorMatchesSearch(identity, search) {
continue
}
if total >= int64(pageStart) && len(matched) < query.Limit+1 {
matched = append(matched, identity)
}
total++
}
if len(zItems) < int(batchSize) {
break
}
offset += int64(len(zItems))
}
nextCursor := ""
items := matched
if len(matched) > query.Limit {
items = matched[:query.Limit]
lastTimestamp, lastID := identityMirrorCursorKey(items[len(items)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
return IdentityMirrorPageResult{
Items: items,
Total: total,
Cursor: query.Cursor,
NextCursor: nextCursor,
}, nil
}
func identityMirrorScoreTime(identity KratosIdentity) time.Time {
if identity.CreatedAt.IsZero() {
return time.Unix(0, 0).UTC()
}
return identity.CreatedAt.UTC()
}
func identityMirrorCursorKey(identity KratosIdentity) (time.Time, string) {
return identityMirrorScoreTime(identity), identity.ID
}
func identityMirrorTenantKeys(traits map[string]any) []string {
keys := make([]string, 0, 4)
seen := make(map[string]bool)
appendKey := func(value string) {
key := strings.ToLower(strings.TrimSpace(value))
if key == "" || seen[key] {
return
}
seen[key] = true
keys = append(keys, key)
}
appendKey(identityMirrorTraitString(traits, "tenant_id"))
appendKey(identityMirrorTraitString(traits, "tenantSlug"))
appointments := identityMirrorAppointments(traits["additionalAppointments"])
if len(appointments) == 0 {
if metadata, ok := traits["metadata"].(map[string]any); ok {
appointments = identityMirrorAppointments(metadata["additionalAppointments"])
}
}
for _, appointment := range appointments {
appendKey(identityMirrorAnyString(appointment["tenantId"]))
appendKey(identityMirrorAnyString(appointment["tenantSlug"]))
appendKey(identityMirrorAnyString(appointment["slug"]))
}
return keys
}
func identityMirrorTargetTenantKeys(query IdentityMirrorPageQuery) map[string]bool {
targets := make(map[string]bool)
for _, value := range []string{query.TenantID, query.TenantSlug} {
key := strings.ToLower(strings.TrimSpace(value))
if key != "" {
targets[key] = true
}
}
return targets
}
func identityMirrorMatchesTenantScope(identity KratosIdentity, targetTenantKeys map[string]bool, allowedTenantKeys map[string]bool) bool {
identityKeys := identityMirrorTenantKeys(identity.Traits)
if len(allowedTenantKeys) > 0 && !identityMirrorAnyKeyAllowed(identityKeys, allowedTenantKeys) {
return false
}
if len(targetTenantKeys) > 0 && !identityMirrorAnyKeyAllowed(identityKeys, targetTenantKeys) {
return false
}
return true
}
func identityMirrorAnyKeyAllowed(keys []string, allowed map[string]bool) bool {
for _, key := range keys {
if allowed[key] {
return true
}
}
return false
}
func identityMirrorMatchesSearch(identity KratosIdentity, search string) bool {
search = strings.TrimSpace(search)
if search == "" {
return true
}
values := []string{
identity.ID,
identityMirrorTraitString(identity.Traits, "email"),
identityMirrorTraitString(identity.Traits, "name"),
identityMirrorTraitString(identity.Traits, "phone_number"),
identityMirrorTraitString(identity.Traits, "loginId"),
}
for _, value := range values {
if strings.Contains(strings.ToLower(value), search) {
return true
}
}
rawTraits, err := json.Marshal(identity.Traits)
if err != nil {
return false
}
return strings.Contains(strings.ToLower(string(rawTraits)), search)
}
func identityMirrorTraitString(traits map[string]any, key string) string {
if traits == nil {
return ""
}
return identityMirrorAnyString(traits[key])
}
func identityMirrorAnyString(value any) string {
switch typed := value.(type) {
case string:
return typed
case fmt.Stringer:
return typed.String()
default:
return ""
}
}
func identityMirrorAppointments(value any) []map[string]any {
switch typed := value.(type) {
case []map[string]any:
return typed
case []any:
result := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if appointment, ok := item.(map[string]any); ok {
result = append(result, appointment)
}
}
return result
default:
return nil
}
}
func (s *RedisService) countIdentityCacheKeys(ctx context.Context) (int64, error) {
keys, err := s.identityCacheKeys(ctx)
if err != nil {
+5 -13
View File
@@ -100,22 +100,14 @@ func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([
return []domain.Tenant{}, nil
}
ownerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
idMap := make(map[string]bool)
allIDs := make([]string, 0, len(memberIDs))
for _, id := range memberIDs {
id = strings.TrimSpace(id)
if id == "" || idMap[id] {
continue
}
idMap[id] = true
}
for _, id := range ownerIDs {
idMap[id] = true
}
for _, id := range adminIDs {
idMap[id] = true
}
allIDs := make([]string, 0, len(idMap))
for id := range idMap {
allIDs = append(allIDs, id)
}
@@ -53,7 +53,11 @@ func (m *MockTenantRepoForSvc) FindByDomain(ctx context.Context, domainName stri
}
func (m *MockTenantRepoForSvc) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
return nil, nil
args := m.Called(ctx, ids)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
@@ -343,3 +347,29 @@ func TestTenantService_ListTenants(t *testing.T) {
assert.Equal(t, tenants, result)
mockRepo.AssertExpectations(t)
}
func TestTenantService_ListJoinedTenants_UsesOnlyMemberRelations(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc)
mockKeto := new(MockKetoSvcForTenant)
svc := NewTenantService(mockRepo, nil, nil, nil)
svc.SetKetoService(mockKeto)
ctx := context.Background()
userID := "user-uuid"
memberTenant := domain.Tenant{ID: "tenant-member", Slug: "actual-member"}
mockKeto.On("ListObjects", ctx, "Tenant", "members", "User:"+userID).
Return([]string{memberTenant.ID}, nil).
Once()
mockRepo.On("FindByIDs", ctx, []string{memberTenant.ID}).
Return([]domain.Tenant{memberTenant}, nil).
Once()
result, err := svc.ListJoinedTenants(ctx, userID)
assert.NoError(t, err)
assert.Equal(t, []domain.Tenant{memberTenant}, result)
mockKeto.AssertNotCalled(t, "ListObjects", ctx, "Tenant", "owners", "User:"+userID)
mockKeto.AssertNotCalled(t, "ListObjects", ctx, "Tenant", "admins", "User:"+userID)
mockKeto.AssertExpectations(t)
mockRepo.AssertExpectations(t)
}
+302 -5
View File
@@ -53,6 +53,8 @@ type WorksmobileHTTPClient struct {
DomainIDs []int64
OrgUnitWriteDelay time.Duration
tokenCache worksmobileAccessTokenCache
levelCache map[int64][]WorksmobileUserLevel
levelCacheMu sync.Mutex
now func() time.Time
}
@@ -326,8 +328,21 @@ func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID str
}
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
var err error
payload = normalizeWorksmobileUserCreatePayload(payload)
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
levelDomainID := worksmobilePayloadLevelDomainID(payload)
levelID, err := c.resolveWorksmobilePayloadLevelIDForDomain(ctx, payload, levelDomainID)
if err != nil {
return err
}
payload.LevelID = ""
if err := c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload); err != nil {
return err
}
if levelID != "" {
return c.PatchUserOrganizationLevelByName(ctx, payload.Email, levelDomainID, levelID)
}
return nil
}
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
@@ -346,6 +361,12 @@ func (c *WorksmobileHTTPClient) UpdateUserOnly(ctx context.Context, payload Work
}
func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, payload WorksmobileUserPayload) error {
levelDomainID := worksmobilePayloadLevelDomainID(payload)
levelID, err := c.resolveWorksmobilePayloadLevelIDForDomain(ctx, payload, levelDomainID)
if err != nil {
return err
}
payload.LevelID = ""
identifier := strings.TrimSpace(payload.Email)
if identifier == "" {
identifier = strings.TrimSpace(payload.UserExternalKey)
@@ -369,6 +390,9 @@ func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, paylo
}
return patchErr
}
if levelID != "" {
return c.PatchUserOrganizationLevelByName(ctx, identifier, levelDomainID, levelID)
}
return nil
}
@@ -585,6 +609,221 @@ func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload)
}
func (c *WorksmobileHTTPClient) PatchUserLevel(ctx context.Context, identifier string, domainID int64, levelID string) error {
identifier = strings.TrimSpace(identifier)
levelID = strings.TrimSpace(levelID)
if identifier == "" {
return fmt.Errorf("worksmobile user identifier is required")
}
if domainID <= 0 {
return fmt.Errorf("worksmobile domain id is required")
}
if levelID == "" {
return nil
}
payload := map[string]any{
"domainId": domainID,
"level": WorksmobileUserLevelRef{
LevelID: levelID,
},
}
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload)
}
func (c *WorksmobileHTTPClient) PatchUserLevelByName(ctx context.Context, identifier string, domainID int64, levelName string) error {
payload, err := c.resolveWorksmobilePayloadLevelID(ctx, WorksmobileUserPayload{
DomainID: domainID,
LevelID: levelName,
})
if err != nil {
return err
}
return c.PatchUserLevel(ctx, identifier, domainID, payload.LevelID)
}
func (c *WorksmobileHTTPClient) PatchUserOrganizationLevelByName(ctx context.Context, identifier string, domainID int64, levelName string) error {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return fmt.Errorf("worksmobile user identifier is required")
}
payload, err := c.resolveWorksmobilePayloadLevelID(ctx, WorksmobileUserPayload{
DomainID: domainID,
LevelID: levelName,
})
if err != nil {
return err
}
var raw map[string]any
if err := c.getDirectoryJSON(ctx, "/v1.0/users/"+url.PathEscape(identifier), &raw); err != nil {
return err
}
rawOrganizations, ok := raw["organizations"].([]any)
if !ok || len(rawOrganizations) == 0 {
return fmt.Errorf("worksmobile user organizations are missing: %s", identifier)
}
organizations := make([]any, 0, len(rawOrganizations))
updated := false
for _, rawOrganization := range rawOrganizations {
organization, ok := rawOrganization.(map[string]any)
if !ok {
continue
}
next := make(map[string]any, len(organization)+1)
for key, value := range organization {
next[key] = value
}
if !updated && worksmobileRawDomainID(next["domainId"]) == domainID {
next["levelId"] = payload.LevelID
updated = true
}
organizations = append(organizations, next)
}
if !updated {
return fmt.Errorf("worksmobile user organization not found for domain_id=%d: %s", domainID, identifier)
}
request := map[string]any{
"domainId": domainID,
"email": firstStringFromMap(raw, "email", "loginId", "userName"),
"userName": raw["userName"],
"organizations": organizations,
}
if value := firstStringFromMap(raw, "userExternalKey", "externalKey", "externalId"); value != "" {
request["userExternalKey"] = value
}
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), request)
}
func worksmobileRawDomainID(raw any) int64 {
switch value := raw.(type) {
case int64:
return value
case int:
return int64(value)
case float64:
return int64(value)
case json.Number:
parsed, _ := value.Int64()
return parsed
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed
default:
return 0
}
}
func (c *WorksmobileHTTPClient) ListUserLevels(ctx context.Context, domainID int64) ([]WorksmobileUserLevel, error) {
if domainID <= 0 {
return nil, fmt.Errorf("worksmobile domain id is required")
}
c.levelCacheMu.Lock()
if c.levelCache != nil {
if cached, ok := c.levelCache[domainID]; ok {
c.levelCacheMu.Unlock()
return cached, nil
}
}
c.levelCacheMu.Unlock()
var response struct {
Levels []WorksmobileUserLevel `json:"levels"`
}
if err := c.getDirectoryJSON(ctx, "/v1.0/users/levels?domainId="+strconv.FormatInt(domainID, 10), &response); err != nil {
return nil, err
}
c.levelCacheMu.Lock()
if c.levelCache == nil {
c.levelCache = map[int64][]WorksmobileUserLevel{}
}
c.levelCache[domainID] = response.Levels
c.levelCacheMu.Unlock()
return response.Levels, nil
}
func (c *WorksmobileHTTPClient) resolveWorksmobilePayloadLevelID(ctx context.Context, payload WorksmobileUserPayload) (WorksmobileUserPayload, error) {
level := strings.TrimSpace(payload.LevelID)
if level == "" {
return payload, nil
}
if isLikelyWorksmobileUUID(level) {
payload.LevelID = level
return payload, nil
}
if isWorksmobileExternalKeyLevelID(level) {
payload.LevelID = level
return payload, nil
}
levels, err := c.ListUserLevels(ctx, payload.DomainID)
if err != nil {
return WorksmobileUserPayload{}, err
}
for _, candidate := range levels {
if strings.TrimSpace(candidate.LevelID) == level || strings.TrimSpace(candidate.LevelName) == level {
payload.LevelID = strings.TrimSpace(candidate.LevelID)
return payload, nil
}
}
return WorksmobileUserPayload{}, fmt.Errorf("worksmobile level not found: domain_id=%d level=%s", payload.DomainID, level)
}
func (c *WorksmobileHTTPClient) resolveWorksmobilePayloadLevelIDForDomain(ctx context.Context, payload WorksmobileUserPayload, domainID int64) (string, error) {
level := strings.TrimSpace(payload.LevelID)
if level == "" {
return "", nil
}
levelPayload := payload
levelPayload.DomainID = domainID
resolved, err := c.resolveWorksmobilePayloadLevelID(ctx, levelPayload)
if err != nil {
return "", err
}
return strings.TrimSpace(resolved.LevelID), nil
}
func worksmobilePayloadLevelDomainID(payload WorksmobileUserPayload) int64 {
if payload.LevelDomainID > 0 {
return payload.LevelDomainID
}
if domainID := worksmobilePayloadPrimaryOrganizationDomainID(payload); domainID > 0 {
return domainID
}
return payload.DomainID
}
func worksmobilePayloadPrimaryOrganizationDomainID(payload WorksmobileUserPayload) int64 {
for _, organization := range payload.Organizations {
if organization.Primary && organization.DomainID > 0 {
return organization.DomainID
}
}
for _, organization := range payload.Organizations {
if organization.DomainID > 0 {
return organization.DomainID
}
}
return 0
}
func isLikelyWorksmobileUUID(value string) bool {
value = strings.TrimSpace(value)
if len(value) != 36 {
return false
}
for i, ch := range value {
if i == 8 || i == 13 || i == 18 || i == 23 {
if ch != '-' {
return false
}
continue
}
if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') && (ch < 'A' || ch > 'F') {
return false
}
}
return true
}
func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) error {
userID = strings.TrimSpace(userID)
if userID == "" {
@@ -1074,6 +1313,15 @@ type WorksmobileUserPatchPayload struct {
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
}
type WorksmobileUserLevelRef struct {
LevelID string `json:"levelId"`
}
type WorksmobileUserLevel struct {
LevelID string `json:"levelId"`
LevelName string `json:"levelName"`
}
type WorksmobileOrgUnitPatchPayload struct {
DomainID int64 `json:"domainId"`
Email string `json:"email,omitempty"`
@@ -1268,6 +1516,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
"employeeId",
"employeeID",
),
DomainID: worksmobileRawDomainID(resource["domainId"]),
LevelID: parseWorksmobileUserLevelID(resource),
LevelName: parseWorksmobileUserLevelName(resource),
Task: firstStringFromMap(resource, "task", "job", "jobDescription"),
@@ -1396,6 +1645,9 @@ func parseWorksmobileUserLevelID(resource map[string]any) string {
if level, ok := resource["level"].(map[string]any); ok {
return firstStringFromMap(level, "levelId", "id", "value")
}
if value := parseWorksmobileOrganizationLevel(resource, "levelId", "id", "value"); value != "" {
return value
}
return ""
}
@@ -1406,9 +1658,42 @@ func parseWorksmobileUserLevelName(resource map[string]any) string {
if level, ok := resource["level"].(map[string]any); ok {
return firstStringFromMap(level, "levelName", "displayName", "name")
}
if value := parseWorksmobileOrganizationLevel(resource, "levelName", "displayName", "name"); value != "" {
return value
}
return ""
}
func parseWorksmobileOrganizationLevel(resource map[string]any, keys ...string) string {
rawOrganizations, ok := resource["organizations"].([]any)
if !ok {
return ""
}
fallback := ""
for _, raw := range rawOrganizations {
organization, ok := raw.(map[string]any)
if !ok {
continue
}
value := firstStringFromMap(organization, keys...)
if value == "" {
if level, ok := organization["level"].(map[string]any); ok {
value = firstStringFromMap(level, keys...)
}
}
if value == "" {
continue
}
if boolFromMap(organization, "primary") {
return value
}
if fallback == "" {
fallback = value
}
}
return fallback
}
type worksmobileOrgUnitDetail struct {
ID string
Name string
@@ -1481,11 +1766,23 @@ func parseWorksmobileUserOrganizationList(raw any) []WorksmobileUserOrganization
if len(orgUnits) == 0 {
continue
}
levelID := firstStringFromMap(organization, "levelId")
levelName := firstStringFromMap(organization, "levelName")
if level, ok := organization["level"].(map[string]any); ok {
if levelID == "" {
levelID = firstStringFromMap(level, "levelId", "id", "value")
}
if levelName == "" {
levelName = firstStringFromMap(level, "levelName", "displayName", "name")
}
}
organizations = append(organizations, WorksmobileUserOrganization{
DomainID: int64FromMap(organization, "domainId"),
Email: firstStringFromMap(organization, "email"),
Primary: boolFromMap(organization, "primary"),
OrgUnits: orgUnits,
DomainID: int64FromMap(organization, "domainId"),
Email: firstStringFromMap(organization, "email"),
Primary: boolFromMap(organization, "primary"),
LevelID: levelID,
LevelName: levelName,
OrgUnits: orgUnits,
})
}
return organizations
@@ -92,10 +92,263 @@ func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *tes
DomainID: 1001,
Email: "phone-canonical@samaneng.com",
CellPhone: "+82+821062836786",
LevelID: "level-manager",
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:tenant-1", Primary: true}},
},
},
})
require.Equal(t, "+82 01062836786", payload.CellPhone)
data, err := json.Marshal(payload)
require.NoError(t, err)
require.NotContains(t, string(data), "level-manager")
}
func TestWorksmobileHTTPClientUpdateUserResolvesLevelNameToLevelObject(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{"levels":[{"levelId":"level-deputy","levelName":"대리"}]}`},
{statusCode: http.StatusOK, body: `{}`},
{statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":true,"orgUnits":[{"orgUnitId":"works-org","primary":true}]}]}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "grade@samaneng.com",
LevelID: "대리",
UserName: WorksmobileUserName{LastName: "Grade User"},
})
require.NoError(t, err)
require.Len(t, transport.requests, 4)
require.Equal(t, http.MethodGet, transport.requests[0].Method)
require.Equal(t, "/v1.0/users/levels", transport.requests[0].URL.Path)
require.Equal(t, "300286336", transport.requests[0].URL.Query().Get("domainId"))
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
require.Equal(t, http.MethodGet, transport.requests[2].Method)
require.Equal(t, http.MethodPatch, transport.requests[3].Method)
var fullPatch map[string]any
require.Len(t, transport.requestBodies, 2)
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &fullPatch))
require.NotContains(t, fullPatch, "level")
require.NotContains(t, fullPatch, "levelId")
var levelPatch map[string]any
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
organizations := levelPatch["organizations"].([]any)
organization := organizations[0].(map[string]any)
require.Equal(t, "level-deputy", organization["levelId"])
require.NotContains(t, levelPatch, "levelId")
}
func TestWorksmobileHTTPClientUpdateUserPassesLevelExternalKeyThrough(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{}`},
{statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":true,"orgUnits":[{"orgUnitId":"works-org","primary":true}]}]}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "grade@samaneng.com",
LevelID: "externalKey:lead",
UserName: WorksmobileUserName{LastName: "Grade User"},
})
require.NoError(t, err)
require.Len(t, transport.requests, 3)
require.Equal(t, http.MethodPatch, transport.requests[0].Method)
require.Equal(t, http.MethodGet, transport.requests[1].Method)
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
var levelPatch map[string]any
require.Len(t, transport.requestBodies, 2)
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
organizations := levelPatch["organizations"].([]any)
organization := organizations[0].(map[string]any)
require.Equal(t, "externalKey:lead", organization["levelId"])
}
func TestWorksmobileHTTPClientUpdateUserInfersLevelDomainFromPrimaryOrganization(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{}`},
{statusCode: http.StatusOK, body: `{"domainId":300285955,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300285955,"primary":false,"orgUnits":[{"orgUnitId":"works-saman","primary":true}]},{"domainId":300286337,"primary":true,"orgUnits":[{"orgUnitId":"works-gpdtdc","primary":true}]}]}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
DomainID: 300285955,
Email: "grade@samaneng.com",
LevelID: "externalKey:prin",
UserName: WorksmobileUserName{LastName: "Grade User"},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300286337,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:gpdtdc", Primary: true}},
},
{
DomainID: 300285955,
Primary: false,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:saman", Primary: true}},
},
},
})
require.NoError(t, err)
require.Len(t, transport.requests, 3)
var levelPatch map[string]any
require.Len(t, transport.requestBodies, 2)
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
require.Equal(t, float64(300286337), levelPatch["domainId"])
organizations := levelPatch["organizations"].([]any)
require.NotContains(t, organizations[0].(map[string]any), "levelId")
require.Equal(t, "externalKey:prin", organizations[1].(map[string]any)["levelId"])
}
func TestWorksmobileHTTPClientUpdateUserUsesLevelDomainForOrganizationLevel(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{"levels":[{"levelId":"level-principal","levelName":"수석"}]}`},
{statusCode: http.StatusOK, body: `{}`},
{statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":false,"orgUnits":[{"orgUnitId":"works-hanmac","primary":true}]},{"domainId":300286337,"primary":true,"orgUnits":[{"orgUnitId":"works-gsim","primary":true}]}]}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
LevelDomainID: 300286337,
Email: "grade@samaneng.com",
LevelID: "수석",
UserName: WorksmobileUserName{LastName: "Grade User"},
})
require.NoError(t, err)
require.Len(t, transport.requests, 4)
require.Equal(t, http.MethodGet, transport.requests[0].Method)
require.Equal(t, "/v1.0/users/levels", transport.requests[0].URL.Path)
require.Equal(t, "300286337", transport.requests[0].URL.Query().Get("domainId"))
var levelPatch map[string]any
require.Len(t, transport.requestBodies, 2)
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
require.Equal(t, float64(300286337), levelPatch["domainId"])
organizations := levelPatch["organizations"].([]any)
require.NotContains(t, organizations[0].(map[string]any), "levelId")
require.Equal(t, "level-principal", organizations[1].(map[string]any)["levelId"])
}
func TestDecodeWorksmobileUserRequestAcceptsStoredLevelName(t *testing.T) {
var payload WorksmobileUserPayload
err := decodeWorksmobileRequest(domain.JSONMap{
"request": map[string]any{
"domainId": int64(300286336),
"email": "grade@samaneng.com",
"levelName": "대리",
"userName": map[string]any{"lastName": "Grade User"},
},
}, &payload)
require.NoError(t, err)
require.Equal(t, "대리", payload.LevelID)
}
func TestWorksmobileUserPayloadJSONPreservesLevelDomainID(t *testing.T) {
encoded, err := json.Marshal(WorksmobileUserPayload{
DomainID: 300285955,
LevelDomainID: 300286337,
Email: "tester@samaneng.com",
LevelID: "externalKey:prin",
UserName: WorksmobileUserName{LastName: "Tester"},
})
require.NoError(t, err)
var raw map[string]any
require.NoError(t, json.Unmarshal(encoded, &raw))
require.Equal(t, float64(300286337), raw["levelDomainId"])
var decoded WorksmobileUserPayload
require.NoError(t, json.Unmarshal(encoded, &decoded))
require.Equal(t, int64(300286337), decoded.LevelDomainID)
require.Equal(t, "externalKey:prin", decoded.LevelID)
}
func TestWorksmobileHTTPClientCreateUserSendsLevelInSeparatePatch(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{"levels":[{"levelId":"level-deputy","levelName":"대리"}]}`},
{statusCode: http.StatusOK, body: `{}`},
{statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":true,"orgUnits":[{"orgUnitId":"works-org","primary":true}]}]}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.CreateUser(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "grade@samaneng.com",
LevelID: "대리",
UserName: WorksmobileUserName{LastName: "Grade User"},
})
require.NoError(t, err)
require.Len(t, transport.requests, 4)
require.Equal(t, http.MethodGet, transport.requests[0].Method)
require.Equal(t, http.MethodPost, transport.requests[1].Method)
require.Equal(t, http.MethodGet, transport.requests[2].Method)
require.Equal(t, http.MethodPatch, transport.requests[3].Method)
var createPayload map[string]any
require.Len(t, transport.requestBodies, 2)
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &createPayload))
require.NotContains(t, createPayload, "level")
require.NotContains(t, createPayload, "levelId")
require.NotContains(t, createPayload, "levelName")
var levelPayload map[string]any
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPayload))
organizations := levelPayload["organizations"].([]any)
organization := organizations[0].(map[string]any)
require.Equal(t, "level-deputy", organization["levelId"])
}
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
@@ -1561,6 +1814,26 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
require.True(t, *user.OrgUnitManagers["works-org-1"])
}
func TestParseWorksmobileDirectoryUserReadsOrganizationLevel(t *testing.T) {
user := parseWorksmobileDirectoryUser(map[string]any{
"userId": "works-user",
"email": "tester@samaneng.com",
"userName": map[string]any{
"lastName": "홍길동",
},
"organizations": []any{
map[string]any{
"primary": true,
"levelId": "level-1",
"levelName": "책임",
},
},
})
require.Equal(t, "level-1", user.LevelID)
require.Equal(t, "책임", user.LevelName)
}
func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.T) {
user := parseWorksmobileDirectoryUser(map[string]any{
"userId": "works-user",
@@ -1572,7 +1845,9 @@ func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.
},
"organizations": []any{
map[string]any{
"primary": true,
"primary": true,
"levelId": "level-1",
"levelName": "책임",
"orgUnits": []any{
map[string]any{
"orgUnitId": "externalKey:primary-org",
@@ -1598,7 +1873,9 @@ func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.
require.Equal(t, "EMP001", user.EmployeeNumber)
require.Equal(t, []WorksmobileUserOrganization{
{
Primary: true,
Primary: true,
LevelID: "level-1",
LevelName: "책임",
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:primary-org", Primary: true, IsManager: boolPtr(false)},
{OrgUnitID: "externalKey:secondary-org", Primary: false, IsManager: boolPtr(true)},
+342 -21
View File
@@ -38,6 +38,8 @@ type WorksmobileUserPayload struct {
PrivateEmail string `json:"privateEmail,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
LevelID string `json:"-"`
LevelDomainID int64 `json:"levelDomainId,omitempty"`
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
@@ -70,6 +72,8 @@ func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
PrivateEmail string `json:"privateEmail,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
LevelName string `json:"levelName,omitempty"`
LevelDomainID int64 `json:"levelDomainId,omitempty"`
PasswordConfig *WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
@@ -90,22 +94,75 @@ func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
PrivateEmail: p.PrivateEmail,
AliasEmails: p.AliasEmails,
Locale: p.Locale,
LevelName: strings.TrimSpace(p.LevelID),
LevelDomainID: p.LevelDomainID,
PasswordConfig: passwordConfig,
Task: p.Task,
Organizations: p.Organizations,
})
}
func (p *WorksmobileUserPayload) UnmarshalJSON(data []byte) error {
type payloadJSON struct {
DomainID int64 `json:"domainId"`
Email string `json:"email"`
UserExternalKey string `json:"userExternalKey,omitempty"`
UserName WorksmobileUserName `json:"userName"`
CellPhone string `json:"cellPhone,omitempty"`
EmployeeNumber string `json:"employeeNumber,omitempty"`
PrivateEmail string `json:"privateEmail,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
LevelID string `json:"levelId,omitempty"`
LevelName string `json:"levelName,omitempty"`
LevelDomainID int64 `json:"levelDomainId,omitempty"`
Level *WorksmobileUserLevelRef `json:"level,omitempty"`
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
}
var raw payloadJSON
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
levelID := strings.TrimSpace(raw.LevelName)
if levelID == "" {
levelID = strings.TrimSpace(raw.LevelID)
}
if levelID == "" && raw.Level != nil {
levelID = strings.TrimSpace(raw.Level.LevelID)
}
*p = WorksmobileUserPayload{
DomainID: raw.DomainID,
Email: raw.Email,
UserExternalKey: raw.UserExternalKey,
UserName: raw.UserName,
CellPhone: raw.CellPhone,
EmployeeNumber: raw.EmployeeNumber,
PrivateEmail: raw.PrivateEmail,
AliasEmails: raw.AliasEmails,
Locale: raw.Locale,
LevelID: levelID,
LevelDomainID: raw.LevelDomainID,
PasswordConfig: raw.PasswordConfig,
Task: raw.Task,
Organizations: raw.Organizations,
}
return nil
}
type WorksmobilePasswordResetPayload struct {
Email string `json:"email"`
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
}
type WorksmobileUserOrganization struct {
DomainID int64 `json:"domainId,omitempty"`
Email string `json:"email,omitempty"`
Primary bool `json:"primary"`
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
DomainID int64 `json:"domainId,omitempty"`
Email string `json:"email,omitempty"`
Primary bool `json:"primary"`
LevelID string `json:"levelId,omitempty"`
LevelName string `json:"levelName,omitempty"`
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
}
type WorksmobileUserOrgUnit struct {
@@ -231,6 +288,10 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
if err != nil {
return WorksmobileUserPayload{}, err
}
levelID, levelDomainID, err := worksmobileUserLevel(user, tenantByID, rootConfig)
if err != nil {
return WorksmobileUserPayload{}, err
}
if task == "" {
task = strings.TrimSpace(user.JobTitle)
}
@@ -242,6 +303,8 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: employeeNumber,
Locale: "ko_KR",
LevelID: levelID,
LevelDomainID: levelDomainID,
Task: task,
Organizations: organizations,
}
@@ -254,6 +317,7 @@ type worksmobileAppointment struct {
IsPrimary bool
IsManager bool
HasManager bool
Grade string
JobTitle string
PositionID string
Source string
@@ -267,6 +331,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
appointments = append([]worksmobileAppointment{{
TenantID: tenant.ID,
IsPrimary: true,
Grade: strings.TrimSpace(user.Grade),
JobTitle: strings.TrimSpace(user.JobTitle),
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
}}, appointments...)
@@ -277,6 +342,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
appointments = append([]worksmobileAppointment{{
TenantID: accountDomainTenant.ID,
IsPrimary: true,
Grade: strings.TrimSpace(user.Grade),
JobTitle: strings.TrimSpace(user.JobTitle),
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
}}, appointments...)
@@ -286,6 +352,10 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
organizationIndexByDomainID := map[int64]int{}
seen := map[string]bool{}
task := ""
fallbackOrganizationIndex := -1
fallbackTask := ""
primaryOrganizationIndex := -1
primaryTask := ""
for _, appointment := range appointments {
if appointment.TenantID == "" || seen[appointment.TenantID] {
continue
@@ -303,8 +373,8 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
continue
}
if isWorksmobileDomainRootTenant(appointmentTenant) {
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" && task == "" {
task = strings.TrimSpace(appointment.JobTitle)
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" && primaryTask == "" {
primaryTask = strings.TrimSpace(appointment.JobTitle)
}
seen[appointment.TenantID] = true
continue
@@ -317,50 +387,104 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if err != nil {
return nil, "", err
}
isPrimaryOrganization := !worksmobileOrganizationsHavePrimary(organizations)
levelID, levelName := worksmobileOrganizationLevelForAppointment(appointment, tenantByID)
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
orgUnit := WorksmobileUserOrgUnit{
OrgUnitID: "externalKey:" + appointmentTenant.ID,
Primary: !organizationExists,
PositionID: appointment.PositionID,
}
if appointment.IsPrimary {
orgUnit.Primary = true
}
if appointment.HasManager {
isManager := appointment.IsManager
orgUnit.IsManager = &isManager
}
if organizationExists {
if isPrimaryOrganization {
organizations[organizationIndex].Primary = true
organizations[organizationIndex].Email = worksmobileOrganizationEmail(user, domainTenant)
if appointment.IsPrimary {
for index := range organizations[organizationIndex].OrgUnits {
organizations[organizationIndex].OrgUnits[index].Primary = false
}
}
worksmobileApplyOrganizationLevel(&organizations[organizationIndex], levelID, levelName, appointment.IsPrimary)
organizations[organizationIndex].OrgUnits = append(organizations[organizationIndex].OrgUnits, orgUnit)
} else {
organizationIndexByDomainID[domainID] = len(organizations)
organizationIndex = len(organizations)
organizations = append(organizations, WorksmobileUserOrganization{
DomainID: domainID,
Email: worksmobileOrganizationEmail(user, domainTenant),
Primary: isPrimaryOrganization,
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
DomainID: domainID,
Email: worksmobileOrganizationEmail(user, domainTenant),
LevelID: levelID,
LevelName: levelName,
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
})
}
if isPrimaryOrganization && strings.TrimSpace(appointment.JobTitle) != "" {
task = strings.TrimSpace(appointment.JobTitle)
if fallbackOrganizationIndex == -1 {
fallbackOrganizationIndex = organizationIndex
}
if fallbackTask == "" && strings.TrimSpace(appointment.JobTitle) != "" {
fallbackTask = strings.TrimSpace(appointment.JobTitle)
}
if appointment.IsPrimary && primaryOrganizationIndex == -1 {
primaryOrganizationIndex = organizationIndex
}
if appointment.IsPrimary && primaryTask == "" && strings.TrimSpace(appointment.JobTitle) != "" {
primaryTask = strings.TrimSpace(appointment.JobTitle)
}
seen[appointment.TenantID] = true
}
if len(organizations) == 0 {
if primaryTask != "" {
task = primaryTask
} else {
task = fallbackTask
}
return nil, task, nil
}
if !worksmobileOrganizationsHavePrimary(organizations) {
organizations[0].Primary = true
if len(organizations[0].OrgUnits) > 0 {
organizations[0].OrgUnits[0].Primary = true
}
selectedOrganizationIndex := primaryOrganizationIndex
if selectedOrganizationIndex == -1 {
selectedOrganizationIndex = fallbackOrganizationIndex
}
if selectedOrganizationIndex == -1 {
selectedOrganizationIndex = 0
}
for index := range organizations {
organizations[index].Primary = index == selectedOrganizationIndex
}
if len(organizations[selectedOrganizationIndex].OrgUnits) > 0 && !worksmobileOrgUnitsHavePrimary(organizations[selectedOrganizationIndex].OrgUnits) {
organizations[selectedOrganizationIndex].OrgUnits[0].Primary = true
}
if primaryTask != "" {
task = primaryTask
} else {
task = fallbackTask
}
sortWorksmobileOrganizations(organizations)
return organizations, task, nil
}
func worksmobileOrganizationLevelForAppointment(appointment worksmobileAppointment, tenantByID map[string]domain.Tenant) (string, string) {
levelID := worksmobileLevelIDForTenant(appointment.Grade, appointment.TenantID, tenantByID)
if levelID == "" {
return "", ""
}
if isWorksmobileExternalKeyLevelID(levelID) {
return levelID, WorksmobileLevelDisplayNameForIdentifier(levelID)
}
return "", levelID
}
func worksmobileApplyOrganizationLevel(organization *WorksmobileUserOrganization, levelID, levelName string, prefer bool) {
if organization == nil || (strings.TrimSpace(levelID) == "" && strings.TrimSpace(levelName) == "") {
return
}
if (strings.TrimSpace(organization.LevelID) == "" && strings.TrimSpace(organization.LevelName) == "") || prefer {
organization.LevelID = levelID
organization.LevelName = levelName
}
}
func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment, tenantID string) bool {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
@@ -466,6 +590,15 @@ func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganiza
return false
}
func worksmobileOrgUnitsHavePrimary(orgUnits []WorksmobileUserOrgUnit) bool {
for _, orgUnit := range orgUnits {
if orgUnit.Primary {
return true
}
}
return false
}
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
rawAppointments, ok := metadata["additionalAppointments"].([]any)
if !ok {
@@ -480,6 +613,7 @@ func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileA
appointment := worksmobileAppointment{
TenantID: metadataString(domain.JSONMap(item), "tenantId", "tenant_id"),
IsPrimary: metadataBool(domain.JSONMap(item), "isPrimary", "primary"),
Grade: metadataString(domain.JSONMap(item), "grade"),
JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"),
PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"),
Source: metadataString(domain.JSONMap(item), "assignmentSource", "source"),
@@ -493,6 +627,193 @@ func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileA
return appointments
}
func worksmobileUserGrade(user domain.User) string {
grade, _ := worksmobileUserGradeWithTenant(user)
return grade
}
func worksmobileUserLevel(user domain.User, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (string, int64, error) {
grade, tenantID := worksmobileUserGradeWithTenant(user)
grade = worksmobileLevelIDForTenant(grade, tenantID, tenantByID)
if grade == "" {
return "", 0, nil
}
tenant, ok := tenantByID[strings.TrimSpace(tenantID)]
if !ok {
return grade, 0, nil
}
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
if err != nil {
return "", 0, err
}
return grade, domainID, nil
}
func worksmobileUserGradeWithTenant(user domain.User) (string, string) {
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
for _, appointment := range appointments {
if appointment.IsPrimary && strings.TrimSpace(appointment.Grade) != "" {
return strings.TrimSpace(appointment.Grade), strings.TrimSpace(appointment.TenantID)
}
}
for _, appointment := range appointments {
if strings.TrimSpace(appointment.Grade) != "" {
return strings.TrimSpace(appointment.Grade), strings.TrimSpace(appointment.TenantID)
}
}
return "", ""
}
const worksmobileExternalKeyLevelIDPrefix = "externalKey:"
type worksmobileGPDTDCLevelMapping struct {
DisplayName string
ExternalKey string
Aliases []string
}
var worksmobileGPDTDCLevelMappings = []worksmobileGPDTDCLevelMapping{
{DisplayName: "사장", ExternalKey: "pres", Aliases: []string{"사장"}},
{DisplayName: "부사장", ExternalKey: "vp", Aliases: []string{"부사장"}},
{DisplayName: "수석 연구원", ExternalKey: "prin", Aliases: []string{"수석", "수석연구원", "수석 연구원"}},
{DisplayName: "책임 연구원", ExternalKey: "lead", Aliases: []string{"책임", "책임연구원", "책임 연구원"}},
{DisplayName: "선임 연구원", ExternalKey: "sen", Aliases: []string{"선임", "선임연구원", "선임 연구원"}},
{DisplayName: "연구원", ExternalKey: "res", Aliases: []string{"연구원"}},
}
func normalizeWorksmobileGradeForTenant(grade, tenantID string, tenantByID map[string]domain.Tenant) string {
grade = strings.TrimSpace(grade)
if grade == "" {
return ""
}
if directorLevel := normalizeWorksmobileDirectorLevelName(grade); directorLevel != "" {
return directorLevel
}
if !worksmobileTenantIsGPDTDCDescendant(tenantID, tenantByID) {
return grade
}
if level, ok := worksmobileGPDTDCLevelMappingForGrade(grade); ok {
return level.DisplayName
}
return grade
}
func normalizeWorksmobileDirectorLevelName(grade string) string {
switch strings.ReplaceAll(strings.TrimSpace(grade), " ", "") {
case "상무":
return "상무이사"
case "전무":
return "전무이사"
default:
return ""
}
}
func worksmobileLevelIDForTenant(grade, tenantID string, tenantByID map[string]domain.Tenant) string {
displayName := normalizeWorksmobileGradeForTenant(grade, tenantID, tenantByID)
if displayName == "" || !worksmobileTenantIsGPDTDCDescendant(tenantID, tenantByID) {
return displayName
}
if level, ok := worksmobileGPDTDCLevelMappingForGrade(displayName); ok {
return worksmobileExternalKeyLevelID(level.ExternalKey)
}
return displayName
}
func worksmobileExternalKeyLevelID(externalKey string) string {
externalKey = strings.TrimSpace(externalKey)
if externalKey == "" {
return ""
}
if strings.HasPrefix(externalKey, worksmobileExternalKeyLevelIDPrefix) {
return externalKey
}
return worksmobileExternalKeyLevelIDPrefix + externalKey
}
func isWorksmobileExternalKeyLevelID(levelID string) bool {
return strings.HasPrefix(strings.TrimSpace(levelID), worksmobileExternalKeyLevelIDPrefix)
}
func worksmobileGPDTDCLevelMappingForGrade(grade string) (worksmobileGPDTDCLevelMapping, bool) {
compact := strings.ReplaceAll(strings.TrimSpace(grade), " ", "")
if compact == "" {
return worksmobileGPDTDCLevelMapping{}, false
}
for _, level := range worksmobileGPDTDCLevelMappings {
for _, alias := range level.Aliases {
if strings.ReplaceAll(strings.TrimSpace(alias), " ", "") == compact {
return level, true
}
}
}
return worksmobileGPDTDCLevelMapping{}, false
}
func worksmobileGPDTDCLevelMappingForExternalKey(levelID string) (worksmobileGPDTDCLevelMapping, bool) {
key := strings.TrimSpace(levelID)
key = strings.TrimPrefix(key, worksmobileExternalKeyLevelIDPrefix)
if key == "" {
return worksmobileGPDTDCLevelMapping{}, false
}
for _, level := range worksmobileGPDTDCLevelMappings {
if level.ExternalKey == key {
return level, true
}
}
return worksmobileGPDTDCLevelMapping{}, false
}
func WorksmobileLevelDisplayNameForIdentifier(levelID string) string {
levelID = strings.TrimSpace(levelID)
if levelID == "" {
return ""
}
if level, ok := worksmobileGPDTDCLevelMappingForExternalKey(levelID); ok {
return level.DisplayName
}
return levelID
}
func WorksmobileLevelIdentifierMatchesRemote(expectedLevelID, remoteLevelID, remoteLevelName string) bool {
expectedLevelID = strings.TrimSpace(expectedLevelID)
remoteLevelID = strings.TrimSpace(remoteLevelID)
remoteLevelName = strings.TrimSpace(remoteLevelName)
if expectedLevelID == "" {
return remoteLevelID == "" && remoteLevelName == ""
}
if remoteLevelID == expectedLevelID || remoteLevelName == expectedLevelID {
return true
}
if worksmobileDirectorLevelNamesEquivalent(expectedLevelID, remoteLevelName) {
return true
}
if level, ok := worksmobileGPDTDCLevelMappingForExternalKey(expectedLevelID); ok {
if remoteLevelID == level.ExternalKey || remoteLevelName == level.DisplayName {
return true
}
for _, alias := range level.Aliases {
if strings.TrimSpace(alias) == remoteLevelName {
return true
}
}
}
return false
}
func worksmobileDirectorLevelNamesEquivalent(expectedLevelName, remoteLevelName string) bool {
expectedLevelName = strings.ReplaceAll(strings.TrimSpace(expectedLevelName), " ", "")
remoteLevelName = strings.ReplaceAll(strings.TrimSpace(remoteLevelName), " ", "")
if expectedLevelName == "" || remoteLevelName == "" {
return false
}
return (expectedLevelName == "상무이사" && remoteLevelName == "상무") ||
(expectedLevelName == "상무" && remoteLevelName == "상무이사") ||
(expectedLevelName == "전무이사" && remoteLevelName == "전무") ||
(expectedLevelName == "전무" && remoteLevelName == "전무이사")
}
func sortWorksmobileOrganizations(organizations []WorksmobileUserOrganization) {
sort.SliceStable(organizations, func(i, j int) bool {
if organizations[i].Primary != organizations[j].Primary {
@@ -91,11 +91,18 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
Email: "john1@samaneng.com",
Name: "John Doe",
Phone: "+19144812222",
Position: "Manager",
Position: "Team Lead",
JobTitle: "Sales management",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"employee_id": "AB001",
"additionalAppointments": []any{
map[string]any{
"tenantId": tenantID,
"isPrimary": true,
"grade": "Manager",
},
},
},
}
tenant := domain.Tenant{
@@ -138,6 +145,7 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
require.Equal(t, "+19144812222", payload.CellPhone)
require.Equal(t, "AB001", payload.EmployeeNumber)
require.Equal(t, "Sales management", payload.Task)
require.Equal(t, "Manager", payload.LevelID)
require.Empty(t, payload.PrivateEmail)
require.Empty(t, payload.AliasEmails)
require.Equal(t, "ko_KR", payload.Locale)
@@ -172,6 +180,71 @@ func TestBuildWorksmobileUserPayloadDeduplicatesKoreanCountryCodeInCellPhone(t *
require.Equal(t, "+821091917771", payload.CellPhone)
}
func TestBuildWorksmobileUserPayloadIgnoresTopLevelUserGrade(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
tenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "john1@samaneng.com",
Name: "John Doe",
Grade: "책임",
TenantID: &tenantID,
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "Saman",
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
require.NoError(t, err)
require.Empty(t, payload.LevelID)
}
func TestBuildWorksmobileUserPayloadNormalizesDirectorLevelNames(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
tenantID := "33333333-3333-3333-3333-333333333333"
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "Saman",
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
for _, tc := range []struct {
grade string
expected string
}{
{grade: "상무", expected: "상무이사"},
{grade: "전무", expected: "전무이사"},
} {
t.Run(tc.grade, func(t *testing.T) {
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "director@samaneng.com",
Name: "Director",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": tenantID,
"isPrimary": true,
"grade": tc.grade,
},
},
},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
require.NoError(t, err)
require.Equal(t, tc.expected, payload.LevelID)
require.Equal(t, int64(1001), payload.LevelDomainID)
})
}
}
func TestWorksmobileUserPayloadJSONOmitsEmptyPasswordConfig(t *testing.T) {
data, err := json.Marshal(WorksmobileUserPayload{
DomainID: 1001,
@@ -315,22 +388,183 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
)
require.NoError(t, err)
require.Equal(t, "PM", payload.Task)
require.Equal(t, "Engineering", payload.Task)
require.Len(t, payload.Organizations, 2)
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
require.NotNil(t, payload.Organizations[0].OrgUnits[0].IsManager)
require.True(t, *payload.Organizations[0].OrgUnits[0].IsManager)
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
require.Nil(t, payload.Organizations[0].OrgUnits[0].IsManager)
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
require.Nil(t, payload.Organizations[1].OrgUnits[0].IsManager)
require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
}
func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePrimary(t *testing.T) {
func TestBuildWorksmobileUserPayloadMapsAppointmentGradeToOrganizationLevelName(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "11111111-1111-1111-1111-111111111111"
tenantID := "22222222-2222-2222-2222-222222222222"
user := domain.User{
ID: "33333333-3333-3333-3333-333333333333",
Email: "principal@samaneng.com",
Name: "Principal Researcher",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": tenantID,
"isPrimary": true,
"grade": "수석 연구원",
},
},
},
}
rootTenant := domain.Tenant{
ID: rootID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "general-structure-div",
Name: "일반구조물 div",
Type: domain.TenantTypeOrganization,
ParentID: &rootID,
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
tenant,
map[string]domain.Tenant{
rootID: rootTenant,
tenantID: tenant,
},
nil,
)
require.NoError(t, err)
require.Len(t, payload.Organizations, 1)
require.Equal(t, "수석 연구원", payload.Organizations[0].LevelName)
require.Empty(t, payload.Organizations[0].LevelID)
}
func TestBuildWorksmobileUserPayloadUsesPrimaryAppointmentGradeForOrganizationLevel(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "11111111-1111-1111-1111-111111111111"
firstTenantID := "22222222-2222-2222-2222-222222222222"
primaryTenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "primary-grade@samaneng.com",
Name: "Primary Grade User",
TenantID: &primaryTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": firstTenantID,
"isPrimary": false,
"grade": "책임",
},
map[string]any{
"tenantId": primaryTenantID,
"isPrimary": true,
"grade": "수석 연구원",
},
},
},
}
rootTenant := domain.Tenant{
ID: rootID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
firstTenant := domain.Tenant{
ID: firstTenantID,
Slug: "first-team",
Name: "First Team",
Type: domain.TenantTypeOrganization,
ParentID: &rootID,
}
primaryTenant := domain.Tenant{
ID: primaryTenantID,
Slug: "primary-team",
Name: "Primary Team",
Type: domain.TenantTypeOrganization,
ParentID: &rootID,
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
primaryTenant,
map[string]domain.Tenant{
rootID: rootTenant,
firstTenantID: firstTenant,
primaryTenantID: primaryTenant,
},
nil,
)
require.NoError(t, err)
require.Len(t, payload.Organizations, 1)
require.Equal(t, "수석 연구원", payload.Organizations[0].LevelName)
require.Empty(t, payload.Organizations[0].LevelID)
}
func TestBuildWorksmobileUserPayloadMapsGPDTDCAppointmentGradeToOrganizationLevelID(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "11111111-1111-1111-1111-111111111111"
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
tenantID := "22222222-2222-2222-2222-222222222222"
user := domain.User{
ID: "33333333-3333-3333-3333-333333333333",
Email: "principal@baroncs.co.kr",
Name: "GPDTDC Principal",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": tenantID,
"isPrimary": true,
"grade": "수석 연구원",
},
},
},
}
rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
tenant := domain.Tenant{
ID: tenantID,
Slug: "gsim-dev",
Name: "GSIM개발",
Type: domain.TenantTypeOrganization,
ParentID: &gpdtdcID,
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
tenant,
map[string]domain.Tenant{
rootID: rootTenant,
gpdtdcID: gpdtdcTenant,
tenantID: tenant,
},
nil,
)
require.NoError(t, err)
require.Len(t, payload.Organizations, 1)
require.Equal(t, "externalKey:prin", payload.Organizations[0].LevelID)
require.Equal(t, "수석 연구원", payload.Organizations[0].LevelName)
}
func TestBuildWorksmobileUserPayloadUsesPrimaryAppointmentAsWorksmobilePrimary(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
hanmacRootID := "11111111-1111-1111-1111-111111111111"
@@ -398,11 +632,11 @@ func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePri
require.NoError(t, err)
require.Len(t, payload.Organizations, 2)
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+firstTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.Equal(t, "externalKey:"+secondTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
}
@@ -626,6 +860,7 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
map[string]any{
"tenantId": leafTenantID,
"isPrimary": true,
"grade": "수석",
},
},
},
@@ -661,6 +896,8 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
require.NoError(t, err)
require.Equal(t, int64(1001), payload.DomainID)
require.Equal(t, "externalKey:prin", payload.LevelID)
require.Equal(t, int64(1003), payload.LevelDomainID)
require.Len(t, payload.Organizations, 1)
require.Equal(t, int64(1003), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
@@ -669,6 +906,137 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
}
func TestBuildWorksmobileUserPayloadNormalizesGPDTDCResearchLevelName(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "11111111-1111-1111-1111-111111111111"
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "researcher@baroncs.co.kr",
Name: "GPDTDC Researcher",
TenantID: &leafTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": leafTenantID,
"isPrimary": true,
"grade": "책임연구원",
},
},
},
}
rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
leafTenant := domain.Tenant{ID: leafTenantID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
leafTenant,
map[string]domain.Tenant{
rootID: rootTenant,
gpdtdcID: gpdtdcTenant,
leafTenantID: leafTenant,
},
nil,
)
require.NoError(t, err)
require.Equal(t, "externalKey:lead", payload.LevelID)
require.Equal(t, int64(1003), payload.LevelDomainID)
}
func TestBuildWorksmobileUserPayloadUsesGPDLevelCSVExternalKeyForPresident(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "11111111-1111-1111-1111-111111111111"
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "president@baroncs.co.kr",
Name: "GPDTDC President",
TenantID: &leafTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": leafTenantID,
"isPrimary": true,
"grade": "사장",
},
},
},
}
rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
leafTenant := domain.Tenant{ID: leafTenantID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
leafTenant,
map[string]domain.Tenant{
rootID: rootTenant,
gpdtdcID: gpdtdcTenant,
leafTenantID: leafTenant,
},
nil,
)
require.NoError(t, err)
require.Equal(t, "externalKey:pres", payload.LevelID)
require.Equal(t, int64(1003), payload.LevelDomainID)
}
func TestBuildWorksmobileUserPayloadUsesGPDTDCDirectorLevelNameWithoutExternalKey(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "11111111-1111-1111-1111-111111111111"
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "director@baroncs.co.kr",
Name: "GPDTDC Director",
TenantID: &leafTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": leafTenantID,
"isPrimary": true,
"grade": "전무이사",
},
},
},
}
rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
leafTenant := domain.Tenant{ID: leafTenantID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
leafTenant,
map[string]domain.Tenant{
rootID: rootTenant,
gpdtdcID: gpdtdcTenant,
leafTenantID: leafTenant,
},
nil,
)
require.NoError(t, err)
require.Equal(t, "전무이사", payload.LevelID)
require.Equal(t, int64(1003), payload.LevelDomainID)
}
func TestWorksmobileLevelIdentifierMatchesRemoteAcceptsGPDLevelAliases(t *testing.T) {
require.True(t, WorksmobileLevelIdentifierMatchesRemote("externalKey:prin", "91515bed-0d5f-4711-78fa-03894597fd2c", "수석연구원"))
require.True(t, WorksmobileLevelIdentifierMatchesRemote("externalKey:lead", "8fde782c-1a46-4bd6-7653-0344a3f66fa5", "책임연구원"))
require.True(t, WorksmobileLevelIdentifierMatchesRemote("externalKey:sen", "8c272083-3cca-47a0-79e2-039cba57b2cc", "선임연구원"))
}
func TestWorksmobileLevelIdentifierMatchesRemoteAcceptsDirectorLevelAliases(t *testing.T) {
require.True(t, WorksmobileLevelIdentifierMatchesRemote("상무이사", "level-managing-director", "상무"))
require.True(t, WorksmobileLevelIdentifierMatchesRemote("전무이사", "level-executive-director", "전무"))
}
func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) {
payload := WorksmobileUserPayload{
Email: "user@samaneng.com",
@@ -290,6 +290,9 @@ func decodeWorksmobileRequest(payload domain.JSONMap, target any) error {
if err != nil {
return err
}
if _, ok := target.(*WorksmobileUserPayload); ok {
return json.Unmarshal(data, target)
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
return decoder.Decode(target)
@@ -107,49 +107,69 @@ type WorksmobileComparison struct {
}
type WorksmobileComparisonItem struct {
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
BaronSlug string `json:"baronSlug,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPhone string `json:"baronPhone,omitempty"`
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
BaronParentSlug string `json:"baronParentSlug,omitempty"`
BaronParentName string `json:"baronParentName,omitempty"`
WorksmobileID string `json:"worksmobileId,omitempty"`
ExternalKey string `json:"externalKey,omitempty"`
WorksmobileName string `json:"worksmobileName,omitempty"`
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileTask string `json:"worksmobileTask,omitempty"`
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
UpdateReasons []string `json:"updateReasons,omitempty"`
Status string `json:"status"`
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
BaronSlug string `json:"baronSlug,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPhone string `json:"baronPhone,omitempty"`
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
BaronGrade string `json:"baronGrade,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
BaronParentSlug string `json:"baronParentSlug,omitempty"`
BaronParentName string `json:"baronParentName,omitempty"`
WorksmobileID string `json:"worksmobileId,omitempty"`
ExternalKey string `json:"externalKey,omitempty"`
WorksmobileName string `json:"worksmobileName,omitempty"`
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileTask string `json:"worksmobileTask,omitempty"`
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
UserMemberships []WorksmobileUserMembershipComparison `json:"userMemberships,omitempty"`
UpdateReasons []string `json:"updateReasons,omitempty"`
Status string `json:"status"`
}
type WorksmobileUserMembershipComparison struct {
BaronOrgID string `json:"baronOrgId,omitempty"`
BaronOrgSlug string `json:"baronOrgSlug,omitempty"`
BaronOrgName string `json:"baronOrgName,omitempty"`
BaronGrade string `json:"baronGrade,omitempty"`
BaronPrimary bool `json:"baronPrimary,omitempty"`
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
WorksmobileOrgID string `json:"worksmobileOrgId,omitempty"`
WorksmobileOrgName string `json:"worksmobileOrgName,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileOrgPositionID string `json:"worksmobileOrgPositionId,omitempty"`
WorksmobileOrgIsManager *bool `json:"worksmobileOrgIsManager,omitempty"`
WorksmobilePrimary bool `json:"worksmobilePrimary,omitempty"`
GradeNeedsUpdate bool `json:"gradeNeedsUpdate,omitempty"`
}
type worksmobileSyncService struct {
@@ -362,7 +382,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
tenantIDs = append(tenantIDs, tenant.ID)
}
}
users, err := s.comparisonUsers(ctx, tenantIDs)
users, err := s.comparisonUsers(ctx, tenantIDs, tenantByID)
if err != nil {
return WorksmobileComparison{}, err
}
@@ -383,7 +403,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
}, nil
}
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) {
if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
if err == nil &&
@@ -394,32 +414,100 @@ func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs
if err != nil {
return nil, err
}
return worksmobileUsersFromIdentityMirror(identities, tenantIDs), nil
return worksmobileUsersFromIdentityMirror(identities, tenantIDs, tenantByID), nil
}
}
return s.userRepo.FindByTenantIDs(ctx, tenantIDs)
}
func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string) []domain.User {
func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string, tenantMaps ...map[string]domain.Tenant) []domain.User {
allowed := make(map[string]bool, len(tenantIDs))
for _, tenantID := range tenantIDs {
allowed[strings.TrimSpace(tenantID)] = true
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
continue
}
allowed[strings.ToLower(tenantID)] = true
if len(tenantMaps) > 0 {
if tenant, ok := tenantMaps[0][tenantID]; ok {
if slug := strings.TrimSpace(tenant.Slug); slug != "" {
allowed[strings.ToLower(slug)] = true
}
}
}
}
users := make([]domain.User, 0, len(identities))
for _, identity := range identities {
tenantID := traitString(identity.Traits, "tenant_id")
if tenantID == "" || !allowed[tenantID] {
if !worksmobileIdentityMirrorMatchesTenant(identity.Traits, allowed) {
continue
}
user := worksmobileUserFromIdentity(identity)
if user.TenantID == nil || strings.TrimSpace(*user.TenantID) == "" {
if tenantID := worksmobileIdentityMirrorTenantID(identity.Traits, allowed); tenantID != "" {
user.TenantID = &tenantID
}
}
users = append(users, user)
}
return users
}
func worksmobileIdentityMirrorMatchesTenant(traits map[string]any, allowed map[string]bool) bool {
for _, key := range identityMirrorTenantKeys(traits) {
if allowed[strings.ToLower(strings.TrimSpace(key))] {
return true
}
}
return false
}
func worksmobileIdentityMirrorTenantID(traits map[string]any, allowed map[string]bool) string {
appointments := identityMirrorAppointments(traits["additionalAppointments"])
if len(appointments) == 0 {
if metadata, ok := traits["metadata"].(map[string]any); ok {
appointments = identityMirrorAppointments(metadata["additionalAppointments"])
}
}
for _, appointment := range appointments {
if !metadataBool(domain.JSONMap(appointment), "isPrimary", "primary") {
continue
}
if tenantID := worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment, allowed); tenantID != "" {
return tenantID
}
}
for _, appointment := range appointments {
if tenantID := worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment, allowed); tenantID != "" {
return tenantID
}
}
return ""
}
func worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment map[string]any, allowed map[string]bool) string {
tenantID := strings.TrimSpace(identityMirrorAnyString(appointment["tenantId"]))
if tenantID == "" {
tenantID = strings.TrimSpace(identityMirrorAnyString(appointment["tenant_id"]))
}
if tenantID != "" && allowed[strings.ToLower(tenantID)] {
return tenantID
}
tenantSlug := strings.TrimSpace(identityMirrorAnyString(appointment["tenantSlug"]))
if tenantSlug == "" {
tenantSlug = strings.TrimSpace(identityMirrorAnyString(appointment["slug"]))
}
if tenantSlug != "" && allowed[strings.ToLower(tenantSlug)] {
return tenantID
}
return ""
}
func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
metadata := domain.JSONMap{}
for key, value := range identity.Traits {
if key == "grade" {
continue
}
metadata[key] = value
}
tenantID := traitString(identity.Traits, "tenant_id")
@@ -438,7 +526,6 @@ func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
Role: domain.NormalizeRole(traitString(identity.Traits, "role")),
AffiliationType: traitString(identity.Traits, "affiliationType"),
Department: traitString(identity.Traits, "department"),
Grade: traitString(identity.Traits, "grade"),
Position: traitString(identity.Traits, "position"),
JobTitle: traitString(identity.Traits, "jobTitle"),
Metadata: metadata,
@@ -595,10 +682,8 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
return nil, errors.New("worksmobile orgunit not found")
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if tenant, ok := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID); ok {
return s.enqueueOrgUnitUpsert(ctx, root, tenant, scopeTenants)
}
if isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) {
_, matchedLocalPart := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID)
if !matchedLocalPart && isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) {
return nil, errors.New("protected worksmobile domain root orgunit cannot be deleted")
}
item := &domain.WorksmobileOutbox{
@@ -1622,7 +1707,9 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
}
updateReasons := []string(nil)
gradeComparison := worksmobileUserGradeComparison{}
if matched {
gradeComparison = worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID)
updateReasons = worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)
}
needsUpdate := len(updateReasons) > 0
@@ -1630,6 +1717,10 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
matchedRemoteIDs[remote.ID] = true
continue
}
baronGrade, _ := worksmobileUserComparisonGradeWithTenant(user, remote, localTenants, remoteOrgUnitByExternalID)
if strings.TrimSpace(gradeComparison.LocalGrade) != "" {
baronGrade = gradeComparison.LocalGrade
}
item := WorksmobileComparisonItem{
ResourceType: "USER",
BaronID: user.ID,
@@ -1637,6 +1728,7 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
BaronEmail: user.Email,
BaronPhone: user.Phone,
BaronEmployeeNumber: metadataEmployeeNumber(user.Metadata),
BaronGrade: baronGrade,
BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user),
BaronPrimaryOrgSlug: worksmobileUserPrimaryOrgSlug(user, localTenants),
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
@@ -1665,6 +1757,10 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
item.WorksmobileAccountStatus = worksmobileRemoteAccountStatus(remote)
item.WorksmobileLevelID = remote.LevelID
item.WorksmobileLevelName = remote.LevelName
if gradeComparison.NeedsUpdate {
item.WorksmobileLevelID = gradeComparison.RemoteLevelID
item.WorksmobileLevelName = gradeComparison.RemoteLevelName
}
item.WorksmobileTask = remote.Task
item.WorksmobileDomainID = remote.DomainID
item.WorksmobileDomainName = remote.DomainName
@@ -1673,6 +1769,7 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
item.WorksmobilePrimaryOrgPositionID = remote.PrimaryOrgUnitPositionID
item.WorksmobilePrimaryOrgPositionName = remote.PrimaryOrgUnitPositionName
item.WorksmobilePrimaryOrgIsManager = remote.PrimaryOrgUnitIsManager
item.UserMemberships = worksmobileUserMembershipComparisons(user, remote, localTenants, remoteOrgUnitByExternalID, gradeComparison)
matchedRemoteIDs[remote.ID] = true
}
result = append(result, item)
@@ -1779,6 +1876,9 @@ func worksmobileUserUpdateReasons(user domain.User, remote WorksmobileRemoteUser
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
reasons = append(reasons, "employee_number")
}
if worksmobileUserGradeNeedsUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
reasons = append(reasons, "grade")
}
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
reasons = append(reasons, "organization")
}
@@ -1823,6 +1923,229 @@ func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote Worksmobi
return localEmployeeNumber != remoteEmployeeNumber
}
func worksmobileUserGradeNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
return worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID).NeedsUpdate
}
func worksmobileUserComparisonGradeWithTenant(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) (string, string) {
comparison := worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID)
if strings.TrimSpace(comparison.LocalGrade) != "" {
return comparison.LocalGrade, comparison.TenantID
}
tenantID := worksmobileRemotePrimaryTenantID(remote, localTenants, remoteOrgUnitByExternalID)
if tenantID == "" {
tenantID = worksmobileUserComparisonTenantID(user, localTenants)
}
if tenantID == "" {
return "", ""
}
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
if strings.TrimSpace(appointment.TenantID) != tenantID {
continue
}
grade := normalizeWorksmobileGradeForTenant(appointment.Grade, tenantID, localTenants)
if grade == "" {
return "", tenantID
}
return grade, tenantID
}
return "", tenantID
}
type worksmobileUserGradeComparison struct {
NeedsUpdate bool
TenantID string
LocalGrade string
RemoteLevelID string
RemoteLevelName string
}
type worksmobileRemoteOrganizationLevel struct {
levelID string
levelName string
primary bool
}
func worksmobileCompareUserGrade(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) worksmobileUserGradeComparison {
if localTenants == nil {
return worksmobileUserGradeComparison{}
}
remoteLevelsByTenant := worksmobileRemoteOrganizationLevelsByTenant(remote, localTenants, remoteOrgUnitByExternalID)
if len(remoteLevelsByTenant) == 0 {
return worksmobileUserGradeComparison{}
}
fallback := worksmobileUserGradeComparison{}
for _, appointment := range worksmobileGradeComparisonAppointments(user, localTenants) {
tenantID := strings.TrimSpace(appointment.TenantID)
localGrade := normalizeWorksmobileGradeForTenant(appointment.Grade, tenantID, localTenants)
if tenantID == "" || localGrade == "" {
continue
}
if _, ok := localTenants[tenantID]; !ok {
continue
}
remoteLevel, ok := remoteLevelsByTenant[tenantID]
if !ok {
continue
}
comparison := worksmobileUserGradeComparison{
TenantID: tenantID,
LocalGrade: localGrade,
RemoteLevelID: strings.TrimSpace(remoteLevel.levelID),
RemoteLevelName: strings.TrimSpace(remoteLevel.levelName),
}
if fallback.LocalGrade == "" || remoteLevel.primary {
fallback = comparison
}
if comparison.RemoteLevelName == "" && comparison.RemoteLevelID == "" {
comparison.NeedsUpdate = true
return comparison
}
if !worksmobileRemoteLevelMatchesLocalGrade(localGrade, tenantID, comparison.RemoteLevelID, comparison.RemoteLevelName, localTenants) {
comparison.NeedsUpdate = true
return comparison
}
}
return fallback
}
func worksmobileRemoteLevelMatchesLocalGrade(localGrade, tenantID, remoteLevelID, remoteLevelName string, localTenants map[string]domain.Tenant) bool {
localGrade = strings.TrimSpace(localGrade)
remoteLevelID = strings.TrimSpace(remoteLevelID)
remoteLevelName = strings.TrimSpace(remoteLevelName)
if localGrade == "" {
return remoteLevelID == "" && remoteLevelName == ""
}
if remoteLevelName == localGrade || remoteLevelID == localGrade {
return true
}
expectedLevelID := worksmobileLevelIDForTenant(localGrade, tenantID, localTenants)
return WorksmobileLevelIdentifierMatchesRemote(expectedLevelID, remoteLevelID, remoteLevelName)
}
func worksmobileGradeComparisonAppointments(user domain.User, tenantByID map[string]domain.Tenant) []worksmobileAppointment {
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
hasGPDTDCGrade := false
for _, appointment := range appointments {
if strings.TrimSpace(appointment.Grade) == "" {
continue
}
if worksmobileTenantIsGPDTDCDescendant(appointment.TenantID, tenantByID) {
hasGPDTDCGrade = true
break
}
}
if !hasGPDTDCGrade {
return appointments
}
filtered := make([]worksmobileAppointment, 0, len(appointments))
for _, appointment := range appointments {
if worksmobileTenantIsGPDTDCDescendant(appointment.TenantID, tenantByID) {
filtered = append(filtered, appointment)
}
}
return filtered
}
func worksmobileRemoteOrganizationLevelsByTenant(remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) map[string]worksmobileRemoteOrganizationLevel {
result := map[string]worksmobileRemoteOrganizationLevel{}
if localTenants == nil {
return result
}
organizations := remote.Organizations
if len(organizations) == 0 {
organizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
} else {
organizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
}
for _, organization := range organizations {
levelID := strings.TrimSpace(organization.LevelID)
levelName := strings.TrimSpace(organization.LevelName)
if levelID == "" && levelName == "" && (len(organizations) == 1 || organization.Primary) {
levelID = strings.TrimSpace(remote.LevelID)
levelName = strings.TrimSpace(remote.LevelName)
}
for _, orgUnit := range organization.OrgUnits {
tenantID := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
if tenantID == "" {
continue
}
if _, ok := localTenants[tenantID]; !ok {
continue
}
current := worksmobileRemoteOrganizationLevel{
levelID: levelID,
levelName: levelName,
primary: organization.Primary || orgUnit.Primary,
}
existing, ok := result[tenantID]
if !ok || (!existing.primary && current.primary) {
result[tenantID] = current
}
}
}
return result
}
func worksmobileRemotePrimaryTenantID(remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) string {
if localTenants == nil {
return ""
}
for _, orgUnitID := range worksmobileRemotePrimaryOrgUnitIDs(remote) {
canonicalOrgUnitID := worksmobileCanonicalRemoteOrgUnitID(orgUnitID, remoteOrgUnitByExternalID)
tenantID := worksmobileOrgUnitLocalExternalKey(canonicalOrgUnitID)
if tenantID == "" {
continue
}
if _, ok := localTenants[tenantID]; ok {
return tenantID
}
}
return ""
}
func worksmobileIsResearchGrade(values ...string) bool {
for _, value := range values {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
continue
}
if strings.Contains(normalized, "연구원") ||
strings.Contains(normalized, "선임") ||
strings.Contains(normalized, "책임") ||
strings.Contains(normalized, "수석") ||
strings.Contains(normalized, "research") ||
strings.Contains(normalized, "principal") {
return true
}
}
return false
}
func worksmobileTenantIsGPDTDCDescendant(tenantID string, tenantByID map[string]domain.Tenant) bool {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" || tenantByID == nil {
return false
}
visited := map[string]bool{}
currentID := tenantID
for currentID != "" {
if visited[currentID] {
return false
}
visited[currentID] = true
tenant, ok := tenantByID[currentID]
if !ok {
return false
}
if worksmobileTenantDomainIDEnvKey(tenant) == "GPDTDC_DOMAIN_ID" && isWorksmobileDomainRootTenant(tenant) {
return true
}
currentID = worksmobileTenantParentID(tenant)
}
return false
}
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
if localTenants == nil {
return false
@@ -1911,9 +2234,11 @@ func worksmobileRemoteUserLegacyOrganizations(remote WorksmobileRemoteUser, remo
}
return []WorksmobileUserOrganization{
{
DomainID: remote.DomainID,
Email: strings.TrimSpace(remote.Email),
Primary: true,
DomainID: remote.DomainID,
Email: strings.TrimSpace(remote.Email),
Primary: true,
LevelID: strings.TrimSpace(remote.LevelID),
LevelName: strings.TrimSpace(remote.LevelName),
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: worksmobileCanonicalRemoteOrgUnitID(strings.TrimSpace(remote.PrimaryOrgUnitID), remoteOrgUnitByExternalID),
@@ -2009,20 +2334,17 @@ type worksmobileComparableOrgUnit struct {
func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, remote []WorksmobileUserOrganization) bool {
expectedUnits := flattenExpectedWorksmobileUserOrganizations(expected)
remoteUnits := flattenRemoteWorksmobileUserOrganizations(remote)
if len(expectedUnits) != len(remoteUnits) {
if len(expectedUnits) == 0 {
return len(remoteUnits) == 0
}
if len(remoteUnits) == 0 {
return false
}
for key, expectedUnit := range expectedUnits {
remoteUnit, ok := remoteUnits[key]
for key, remoteUnit := range remoteUnits {
expectedUnit, ok := expectedUnits[key]
if !ok {
return false
}
if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary {
return false
}
if expectedUnit.unitPrimary != remoteUnit.unitPrimary {
return false
}
if expectedUnit.comparePosition && strings.TrimSpace(expectedUnit.positionID) != strings.TrimSpace(remoteUnit.positionID) {
return false
}
@@ -2090,6 +2412,145 @@ func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOr
return result
}
type worksmobileRemoteMembershipDetail struct {
domainID int64
domainName string
orgUnitID string
orgUnitName string
levelID string
levelName string
positionID string
manager *bool
primary bool
}
func worksmobileUserMembershipComparisons(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup, gradeComparison worksmobileUserGradeComparison) []WorksmobileUserMembershipComparison {
if localTenants == nil {
return nil
}
tenantID := worksmobileUserComparisonTenantID(user, localTenants)
if tenantID == "" {
return nil
}
tenant, ok := localTenants[tenantID]
if !ok {
return nil
}
expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
if err != nil || len(expectedOrganizations) == 0 {
return nil
}
remoteOrganizations := remote.Organizations
if len(remoteOrganizations) == 0 {
remoteOrganizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
} else {
remoteOrganizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
}
remoteMemberships := worksmobileRemoteMembershipDetailsByKey(remote, remoteOrganizations, remoteOrgUnitByExternalID)
appointments := worksmobileAppointmentsByTenantID(user.Metadata)
result := make([]WorksmobileUserMembershipComparison, 0)
for _, organization := range expectedOrganizations {
for _, orgUnit := range organization.OrgUnits {
baronOrgID := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
if baronOrgID == "" {
continue
}
baronTenant, ok := localTenants[baronOrgID]
if !ok {
continue
}
item := WorksmobileUserMembershipComparison{
BaronOrgID: baronOrgID,
BaronOrgSlug: strings.TrimSpace(baronTenant.Slug),
BaronOrgName: strings.TrimSpace(baronTenant.Name),
BaronPrimary: organization.Primary || orgUnit.Primary,
}
if appointment, ok := appointments[baronOrgID]; ok {
item.BaronGrade = normalizeWorksmobileGradeForTenant(appointment.Grade, baronOrgID, localTenants)
}
key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID)
if remoteMembership, ok := remoteMemberships[key]; ok {
item.WorksmobileDomainID = remoteMembership.domainID
item.WorksmobileDomainName = remoteMembership.domainName
item.WorksmobileOrgID = remoteMembership.orgUnitID
item.WorksmobileOrgName = remoteMembership.orgUnitName
item.WorksmobileLevelID = remoteMembership.levelID
item.WorksmobileLevelName = remoteMembership.levelName
item.WorksmobileOrgPositionID = remoteMembership.positionID
item.WorksmobileOrgIsManager = remoteMembership.manager
item.WorksmobilePrimary = remoteMembership.primary
}
item.GradeNeedsUpdate = gradeComparison.NeedsUpdate && strings.TrimSpace(gradeComparison.TenantID) == baronOrgID
result = append(result, item)
}
}
return result
}
func worksmobileAppointmentsByTenantID(metadata domain.JSONMap) map[string]worksmobileAppointment {
result := map[string]worksmobileAppointment{}
for _, appointment := range worksmobileAppointmentsFromMetadata(metadata) {
tenantID := strings.TrimSpace(appointment.TenantID)
if tenantID == "" {
continue
}
result[tenantID] = appointment
}
return result
}
func worksmobileRemoteMembershipDetailsByKey(remote WorksmobileRemoteUser, organizations []WorksmobileUserOrganization, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) map[string]worksmobileRemoteMembershipDetail {
result := map[string]worksmobileRemoteMembershipDetail{}
for _, organization := range organizations {
domainID := organization.DomainID
if domainID == 0 {
domainID = remote.DomainID
}
domainName := strings.TrimSpace(remote.DomainName)
levelID := strings.TrimSpace(organization.LevelID)
levelName := strings.TrimSpace(organization.LevelName)
if levelID == "" && levelName == "" && (len(organizations) == 1 || organization.Primary) {
levelID = strings.TrimSpace(remote.LevelID)
levelName = strings.TrimSpace(remote.LevelName)
}
for _, orgUnit := range organization.OrgUnits {
key := worksmobileComparableOrgUnitKey(domainID, orgUnit.OrgUnitID)
if key == "" {
continue
}
localExternalKey := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
orgUnitID := strings.TrimSpace(orgUnit.OrgUnitID)
orgUnitName := ""
if localExternalKey != "" {
if remoteGroup, ok := remoteOrgUnitByExternalID[localExternalKey]; ok {
if strings.TrimSpace(remoteGroup.ID) != "" {
orgUnitID = strings.TrimSpace(remoteGroup.ID)
}
orgUnitName = strings.TrimSpace(remoteGroup.DisplayName)
if domainName == "" {
domainName = strings.TrimSpace(remoteGroup.DomainName)
}
}
}
if orgUnitName == "" && worksmobileOrgUnitIDContains([]string{remote.PrimaryOrgUnitID}, orgUnitID) {
orgUnitName = strings.TrimSpace(remote.PrimaryOrgUnitName)
}
result[key] = worksmobileRemoteMembershipDetail{
domainID: domainID,
domainName: domainName,
orgUnitID: orgUnitID,
orgUnitName: orgUnitName,
levelID: levelID,
levelName: levelName,
positionID: strings.TrimSpace(orgUnit.PositionID),
manager: orgUnit.IsManager,
primary: organization.Primary || orgUnit.Primary,
}
}
}
return result
}
func worksmobileComparableOrgUnitKey(domainID int64, orgUnitID string) string {
orgUnitID = strings.TrimSpace(orgUnitID)
if domainID == 0 || orgUnitID == "" {
@@ -2167,10 +2628,7 @@ func worksmobileOrgUnitLocalExternalKey(orgUnitID string) string {
}
func worksmobileUserPrimaryOrgID(user domain.User) string {
if user.TenantID == nil {
return ""
}
return strings.TrimSpace(*user.TenantID)
return worksmobileUserComparisonPrimaryTenantID(user)
}
func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]domain.Tenant) string {
@@ -1200,7 +1200,7 @@ func TestWorksmobileSyncServiceEnqueuesExternalKeyPresentWorksOnlyOrgUnitDelete(
require.Equal(t, "baron-tenant-1", outboxRepo.created[0].Payload["externalKey"])
}
func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *testing.T) {
func TestWorksmobileSyncServiceDeletesWorksOrgUnitEvenWhenSlugLocalPartMatches(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1001")
rootID := "root-tenant"
orgID := "baron-org-1"
@@ -1244,11 +1244,10 @@ func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *test
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, orgID, request.OrgUnitExternalKey)
require.Equal(t, "tech-dev-center", outboxRepo.created[0].Payload["matchLocalPart"])
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
require.Equal(t, "works-org-1", outboxRepo.created[0].ResourceID)
require.Equal(t, "works-org-1", outboxRepo.created[0].Payload["worksmobileId"])
require.Equal(t, "legacy-external-key", outboxRepo.created[0].Payload["externalKey"])
}
func TestCompareWorksmobileGroupsFillsParentDisplayFromBaronParentMatch(t *testing.T) {
@@ -1342,11 +1341,10 @@ func TestWorksmobileSyncServiceReconcilesTopLevelWorksOnlyOrgUnitBeforeProtected
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, orgID, request.OrgUnitExternalKey)
require.Equal(t, "operations", outboxRepo.created[0].Payload["matchLocalPart"])
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
require.Equal(t, "works-operations", outboxRepo.created[0].ResourceID)
require.Equal(t, "works-operations", outboxRepo.created[0].Payload["worksmobileId"])
require.Equal(t, "legacy-operations-id", outboxRepo.created[0].Payload["externalKey"])
}
func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testing.T) {
@@ -2172,6 +2170,403 @@ func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersMarksTenantLinkedGradeChangeNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
gpdtdcID := "tenant-gpdtdc"
tenantID := "tenant-gpdtdc-leaf"
user := domain.User{
ID: "user-grade",
Email: "grade@samaneng.com",
Name: "Grade User",
TenantID: &tenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": tenantID,
"isPrimary": true,
"grade": "책임",
},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-grade",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
LevelName: "",
PrimaryOrgUnitID: "externalKey:" + tenantID,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1003,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + tenantID, Primary: true}},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
require.Contains(t, items[0].UpdateReasons, "grade")
require.Equal(t, "책임 연구원", items[0].BaronGrade)
}
func TestCompareWorksmobileUsersIncludesMembershipMatchForGradeUpdate(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
gpdtdcID := "tenant-gpdtdc"
hmegID := "1d74bebb-c5a1-49d4-bec4-90f0c89ad21f"
user := domain.User{
ID: "user-hmeg-researcher",
Email: "hmeg-researcher@baroncs.co.kr",
Name: "HMEG Researcher",
TenantID: &hmegID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": hmegID,
"isPrimary": true,
"grade": "책임연구원",
},
},
},
}
items := compareWorksmobileUsersWithRemoteGroups(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-hmeg-researcher",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1003,
PrimaryOrgUnitID: "works-hmeg",
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1003,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "works-hmeg", Primary: true}},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
hmegID: {ID: hmegID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
[]WorksmobileRemoteGroup{{
ID: "works-hmeg",
ExternalID: hmegID,
DisplayName: "WORKS HmEG",
DomainID: 1003,
DomainName: "baroncs.co.kr",
}},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
require.Contains(t, items[0].UpdateReasons, "grade")
require.Len(t, items[0].UserMemberships, 1)
require.Equal(t, hmegID, items[0].UserMemberships[0].BaronOrgID)
require.Equal(t, "HmEG", items[0].UserMemberships[0].BaronOrgName)
require.Equal(t, "hmeg", items[0].UserMemberships[0].BaronOrgSlug)
require.Equal(t, "책임 연구원", items[0].UserMemberships[0].BaronGrade)
require.Equal(t, "works-hmeg", items[0].UserMemberships[0].WorksmobileOrgID)
require.Equal(t, "WORKS HmEG", items[0].UserMemberships[0].WorksmobileOrgName)
require.True(t, items[0].UserMemberships[0].GradeNeedsUpdate)
}
func TestCompareWorksmobileUsersDoesNotUpdateWhenWORKSMembershipIsBaronSubset(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
companyID := "tenant-saman"
tenantID := "tenant-saman-leaf"
gpdtdcID := "tenant-gpdtdc"
gpdtdcTenantID := "tenant-is-3"
user := domain.User{
ID: "user-gpdtdc-grade-with-saman-works-org",
Email: "gpdtdc-grade@samaneng.com",
Name: "Research Grade User",
TenantID: &gpdtdcTenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": tenantID,
"isPrimary": true,
"grade": "팀장",
},
map[string]any{
"tenantId": gpdtdcTenantID,
"grade": "책임",
},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-gpdtdc-grade",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
LevelName: "팀장",
PrimaryOrgUnitID: "externalKey:" + tenantID,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + tenantID, Primary: true}},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Slug: "saman-leaf", Name: "삼안 조직", Type: domain.TenantTypeOrganization, ParentID: &companyID},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "is-3", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
require.NotContains(t, items[0].UpdateReasons, "grade")
require.NotContains(t, items[0].UpdateReasons, "organization")
}
func TestCompareWorksmobileUsersDoesNotCompareGPDTDCGradeAgainstSamanOrgChartLevel(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "300285955")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
samanID := "045e0b22-fae7-4229-1724-039c5af16849"
samanOrgChartID := "97a7e34d-2042-4793-27dc-03ffd68db801"
gpdtdcID := "tenant-gpdtdc"
infraBIM1ID := "432b5261-421b-4e5f-914f-32d7d22fd01f"
user := domain.User{
ID: "abaf0788-2d68-4b7d-b40a-c0251f38ae21",
Email: "hwan@samaneng.com",
Name: "안효원",
TenantID: &infraBIM1ID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": samanOrgChartID,
"grade": "선임연구원",
},
map[string]any{
"tenantId": infraBIM1ID,
"isPrimary": true,
"grade": "선임연구원",
},
},
},
}
remoteManager := false
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "045e0b22-fae7-4229-1724-039c5af16849",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 300285955,
PrimaryOrgUnitID: "externalKey:" + samanOrgChartID,
PrimaryOrgUnitName: "삼안기술개발센터(조직도용)",
PrimaryOrgUnitIsManager: &remoteManager,
PrimaryOrgUnitPositionName: "조직장 아님",
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300285955,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{{
OrgUnitID: "externalKey:" + samanOrgChartID,
Primary: true,
IsManager: &remoteManager,
}},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
samanID: {ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
samanOrgChartID: {ID: samanOrgChartID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &samanID},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
infraBIM1ID: {ID: infraBIM1ID, Slug: "infra-bim1", Name: "인프라 BIM1", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
require.NotContains(t, items[0].UpdateReasons, "grade")
}
func TestCompareWorksmobileUsersUsesPrimaryAppointmentForGPDTDCGrade(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
companyID := "tenant-saman"
orgChartTenantID := "tenant-rnd-saman"
gpdtdcID := "tenant-gpdtdc"
gpdtdcTenantID := "tenant-is-3"
user := domain.User{
ID: "user-orgchart-grade",
Email: "orgchart-grade@samaneng.com",
Name: "Orgchart Grade User",
TenantID: &gpdtdcTenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": orgChartTenantID,
},
map[string]any{
"tenantId": gpdtdcTenantID,
"isPrimary": true,
"grade": "수석연구원",
},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-orgchart-grade",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
LevelName: "수석 연구원",
PrimaryOrgUnitID: "externalKey:" + gpdtdcTenantID,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Email: user.Email,
Primary: false,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + orgChartTenantID, Primary: true}},
},
{
DomainID: 1003,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + gpdtdcTenantID, Primary: true}},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
orgChartTenantID: {ID: orgChartTenantID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &companyID},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "is-3", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
require.NotContains(t, items[0].UpdateReasons, "grade")
require.Equal(t, "수석 연구원", items[0].BaronGrade)
}
func TestCompareWorksmobileUsersMarksConcurrentTenantGradeChangeNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
companyID := "tenant-saman"
primaryTenantID := "tenant-saman-leaf"
gpdtdcID := "tenant-gpdtdc"
gpdtdcTenantID := "tenant-is-3"
user := domain.User{
ID: "user-concurrent-grade",
Email: "concurrent-grade@samaneng.com",
Name: "Concurrent Grade User",
TenantID: &primaryTenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": primaryTenantID,
"isPrimary": true,
"grade": "팀장",
},
map[string]any{
"tenantId": gpdtdcTenantID,
"grade": "책임",
},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-concurrent-grade",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
LevelName: "팀장",
PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Email: user.Email,
Primary: true,
LevelName: "팀장",
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + primaryTenantID, Primary: true}},
},
{
DomainID: 1003,
Email: user.Email,
Primary: false,
LevelName: "선임",
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + gpdtdcTenantID, Primary: true}},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
primaryTenantID: {ID: primaryTenantID, Slug: "saman-leaf", Name: "삼안 조직", Type: domain.TenantTypeOrganization, ParentID: &companyID},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "is-3", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
require.Contains(t, items[0].UpdateReasons, "grade")
require.NotContains(t, items[0].UpdateReasons, "organization")
require.Equal(t, "책임 연구원", items[0].BaronGrade)
require.Equal(t, "선임", items[0].WorksmobileLevelName)
}
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-saman"
@@ -2307,6 +2702,134 @@ func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersIgnoresPrimaryPriorityWhenMembershipsMatch(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
companyID := "tenant-saman"
orgChartTenantID := "tenant-rnd-saman"
gpdtdcID := "tenant-gpdtdc"
gpdtdcTenantID := "tenant-gpdtdc-leaf"
user := domain.User{
ID: "user-dual-membership",
Email: "dual-membership@samaneng.com",
Name: "Dual Membership User",
TenantID: &gpdtdcTenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": orgChartTenantID,
"isPrimary": false,
},
map[string]any{
"tenantId": gpdtdcTenantID,
"isPrimary": true,
},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-dual-membership",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
PrimaryOrgUnitID: "externalKey:" + orgChartTenantID,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + orgChartTenantID, Primary: true}},
},
{
DomainID: 1003,
Email: user.Email,
Primary: false,
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + gpdtdcTenantID, Primary: true}},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
orgChartTenantID: {ID: orgChartTenantID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &companyID},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompany, ParentID: &rootID},
gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
require.NotContains(t, items[0].UpdateReasons, "organization")
}
func TestCompareWorksmobileUsersIgnoresBaronMembershipSuperset(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
companyID := "tenant-saman"
primaryTenantID := "tenant-primary"
secondaryTenantID := "tenant-secondary"
user := domain.User{
ID: "user-baron-membership-superset",
Email: "membership-superset@samaneng.com",
Name: "Membership Superset User",
TenantID: &primaryTenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": primaryTenantID,
"isPrimary": true,
},
map[string]any{
"tenantId": secondaryTenantID,
},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-membership-superset",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1001,
PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + primaryTenantID,
Primary: true,
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
primaryTenantID: {ID: primaryTenantID, Slug: "primary", Name: "Primary", Type: domain.TenantTypeOrganization, ParentID: &companyID},
secondaryTenantID: {ID: secondaryTenantID, Slug: "secondary", Name: "Secondary", Type: domain.TenantTypeOrganization, ParentID: &companyID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
require.NotContains(t, items[0].UpdateReasons, "organization")
}
func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
@@ -2357,6 +2880,32 @@ func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t
require.Equal(t, "matched", items[0].Status)
}
func TestWorksmobileUsersFromIdentityMirrorIncludesAdditionalAppointmentMembership(t *testing.T) {
tenantID := "tenant-gpdtdc-leaf"
identity := KratosIdentity{
ID: "64d4a839-ee04-4c47-b7b3-4ac6428c56b1",
Traits: map[string]any{
"email": "researcher@samaneng.com",
"name": "Researcher User",
"additionalAppointments": []any{
map[string]any{
"tenantId": tenantID,
"isPrimary": true,
"grade": "책임",
},
},
},
}
users := worksmobileUsersFromIdentityMirror([]KratosIdentity{identity}, []string{tenantID})
require.Len(t, users, 1)
require.Equal(t, identity.ID, users[0].ID)
require.NotNil(t, users[0].TenantID)
require.Equal(t, tenantID, *users[0].TenantID)
require.Equal(t, "책임", worksmobileUserGrade(users[0]))
}
func TestCompareWorksmobileUsersUsesRemoteUserDomainWhenOrganizationDomainIsMissing(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
@@ -2517,6 +3066,54 @@ func TestCompareWorksmobileUsersMatchesRemoteOrganizationExternalKey(t *testing.
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersDisplaysPrimaryAppointmentAsBaronPrimaryOrg(t *testing.T) {
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
hanmacCompanyID := "tenant-hanmac"
hanmacOrgID := "tenant-hanmac-org"
gpdtdcID := "tenant-gpdtdc"
gsimID := "tenant-gsim-dev"
user := domain.User{
ID: "user-gsim-primary",
Email: "gsim-primary@hanmaceng.co.kr",
Name: "GSIM Primary User",
TenantID: &hanmacOrgID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": hanmacOrgID,
"isPrimary": false,
},
map[string]any{
"tenantId": gsimID,
"isPrimary": true,
},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
nil,
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
hanmacCompanyID: {ID: hanmacCompanyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}},
hanmacOrgID: {ID: hanmacOrgID, Slug: "rnd-hanmac", Name: "한맥기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &hanmacCompanyID},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompany, ParentID: &rootID},
gsimID: {ID: gsimID, Slug: "gsim-dev", Name: "GSIM개발", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
)
require.Len(t, items, 1)
require.Equal(t, "missing_in_worksmobile", items[0].Status)
require.Equal(t, gsimID, items[0].BaronPrimaryOrgID)
require.Equal(t, "gsim-dev", items[0].BaronPrimaryOrgSlug)
require.Equal(t, "GSIM개발", items[0].BaronPrimaryOrgName)
}
func TestCompareWorksmobileUsersMarksMissingPrimaryOrganizationNeedsUpdate(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
@@ -1,31 +1,31 @@
5d2d06696fa6813d604ae0fc4a41d83018d3f58fa81c816533fe00c7ba46da48 ./clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native
d12b45688b414137a44d7162514756b33617046bb5bd2a2fe553d001e9ca7738 ./clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native
1418f34f8c5616446ac91c20e8c5efe451e4fcbbeb1c9acfd55552c02425e725 ./clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native
d4e31971a8e28e3d3b9e307a78b079f55c9420232b8b77301abb0da07a6da925 ./clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native
a764deb4193edb724a29b0489ea7e3052e724cda3f4a3f2b7d6493b7bbb8b9df ./clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native
d358156ecc5703059f7d171a43b7a952d4db2ac704f5d401ef6bd9c92b7bd8c2 ./clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native
9b3eac049187af79f4db488b96f66b5a835807b37c8273cc4fa044e54ff6e1b2 ./clickhouse/baron_clickhouse/schema/baron_sso__audit_logs.sql
6bd39d8db64aad6ef55ff5b9db11993ccf37a3fe1c49460c0e62099a4391925b ./clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate.sql
1b502fcfa9ff305dcd5b4769ff727b6f2500769cf39b37fb780f65a0e609de2a ./clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate_mv.sql
3515ed1f15426aae56b6cc12c4281c456d84477f1571a7ad002cc1869d82c9cd ./clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_events.sql
9c800f51db9a4143fbefb1c79d2046fa85ef66b3174941e17090cb2c4999b7d4 ./clickhouse/baron_clickhouse/tables.tsv
db941d2a9be77eefba1a361cf36265abc09b8392580eaee7da8f037ca1ab6cd8 ./clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native
63e07a72fa838eb82240385899311d015fd5a65064f50f014f3294be499cbbd1 ./clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native
1ee61755b025e757d8f0ea5208d083d4b36d68ea2f79fa3eb34e82b9e53eb7a8 ./clickhouse/ory_clickhouse/schema/ory__oathkeeper_access_logs.sql
214144ca9cdbebde7270738176ffea9d0042dcdad2133b43dc0b1f107cf6197e ./clickhouse/ory_clickhouse/tables.tsv
eefcef288ff99ff8477002e4f979e4ea801f5999e65b5e132c6f58182d87049f ./config/compose/compose.infra.yaml
1c5f4eeae27b294711ea4a5dc5edcb87523724459fba37c6d0868ad11864df1d ./config/compose/compose.ory.yaml
0c57362f2fbf33985d2a3162dba76af8b9860614d94c100fdb3e21f2f1779d8f ./config/compose/docker-compose.yaml
5a8881f50b86726e0de3334470441ddb9a0c48a73c40c8adfc23e68aab59c368 ./config/compose/docker-compose.yaml
8a670f1cff98b75fb8c7f240a6b38d0170fcf85ad40f8680ebd9260a95c64064 ./config/env.redacted
15359cf3f3f96d522ccbd1311ac0811a9b6c3dfae1c4cd9809e85e02787e59af ./config/gateway.tar.zst
125f45e479a0d71b0bed760429c6473bbacfc849ce37a673a03afe829f0cb714 ./config/generated-ory.tar.zst
2108fc3d39c9a29a72759a7b1c1c344bc82915ecef2672b57d6d5f527c8e1284 ./manifest.json
180986d8e311119606ee1cb021458507086285d960805473a1ce0f2ec00c76f4 ./postgres/baron.dump
9c1c9939132d0ac1b260dd38ff7278382511cc1577ef792c5d2e45eb63c01e66 ./postgres/globals.sql
cdf0b5f06148c88027e32515542fb452dfa40429077d8ecaa7dff4c250830f5e ./postgres/ory_hydra.dump
240557fec153b4660ce4e10feeba09398a4ff35dec26b20675c4cb00725438b2 ./postgres/ory_keto.dump
38060e8fe88f86b17b55d745dd6be602b036e68634c6190946c64728b0ac6e6e ./postgres/ory_kratos.dump
e5a062101176bd89c36974ef5edafe43e8b9efacd492c023e46705af158c72ec ./reports/backup-report.md
beffa23b8ff253a25cf73cef0b8b60bad6d5aeafc210d8111ccea1a60d906baf ./config/generated-ory.tar.zst
0bbbad1e60b4c18b1988d9853b574c7b91a9976154c47aee2f6f2a219d57099e ./manifest.json
2932edf10ed8c2863def894c91059e3a7a819746a74f1dc4c31a642a89925571 ./postgres/baron.dump
3bafa171e8168769da41e33e017cb1135ce8b647fd29d38222054b7bb29b434c ./postgres/globals.sql
4ccfaa262e78cc96306817dd5e6beec736905f597b6cdfa7f2adaf4231f314c2 ./postgres/ory_hydra.dump
770b1398b1c72242ada4b6ad0ab9a5bce49b64ba515c4c3d8553e7db0f764b8d ./postgres/ory_keto.dump
aaf23540dd652bc68bb4e7601e7ab91dc12cd50a6f98baf609f2a3a122acf7ea ./postgres/ory_kratos.dump
6bdcc558f1add31d3dba5339d4bed59e3e836e5df2c58718328d7a3b8cdde06a ./reports/backup-report.md
c08472517cf91b001f5cf4dbcf9750460e07ab1f7cd0f953aba79067899a724a ./reports/baron-postgres-custom-claim-counts.txt
a301238b58845a6aa3d2b6f91bbec73fb672040f3527cb12b181a81272fc9793 ./reports/baron-postgres-row-counts.txt
c8e6e5b0ee8c6eb360581aa0ee6120fd839308fe4d457ffb56749f7d4248184e ./reports/baron_clickhouse-row-counts.txt
6b307b0f5be3a386c8b61198af2db45552c6c02992461b9e83eb38fc8f95f75b ./reports/ory_clickhouse-row-counts.txt
fa92334662870832724702f2ac933a942709cae8ec8659b7e004647fc548ad1e ./reports/ory_hydra-row-counts.txt
6d673a8360ab4a250d7f3ff77796444c26fa1ba66db8c341f01a8c01d15b9aea ./reports/ory_keto-row-counts.txt
57484ccd98112c483f6cc903e8fb135bc8c60d6ca3ba1b9733f0859227b6919d ./reports/ory_kratos-row-counts.txt
6f91bc4ae9949e117dad88ac59441f14bc9b2129565538ff125180efa06244db ./reports/baron-postgres-row-counts.txt
0594c3403299c4c869b59edbe3d86da25d46ab586ce89b3392333fa549dcd845 ./reports/baron_clickhouse-row-counts.txt
0c94e98c040e81d5fafe927e501cc0fd45aa01b093685411148961cb452bd5ea ./reports/ory_clickhouse-row-counts.txt
1bc6f516c2a9edcf4290e532f09ed2637cf82c4c430256227e3d8a5dbb063cee ./reports/ory_hydra-row-counts.txt
3468db707ce990a58314d257147bd5d0d94461c0e57cdb2982972d41bcb6622d ./reports/ory_keto-row-counts.txt
b204f65234e114c083a9d45fb6cc05c2dcf7110e9f242c41075caa7b319b4a89 ./reports/ory_kratos-row-counts.txt
@@ -1,4 +1,6 @@


event_dateDateP tenant_idString$f36e2211-8cfd-4813-8618-34e606fe73ac tenant_typeString ORGANIZATION client_idStringorgfront client_nameStringOrgFront
event_typeStringrp_usage.authorization_granted events_countAggregateFunction(count)unique_subjects$AggregateFunction(uniqExact, String)<ðÞX„K>Ob×ÑR
event_dateDatecPdPdPdPhPhPhP…P…P…P…P†P†P†P†P†P†P‡P‡P‡P‡P‡P‡P‡P‡P tenant_idString$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$3a660456-eceb-472b-a9a9-f2a5b0ce972b$52266543-a90b-4441-99c6-51f454b6059a$52266543-a90b-4441-99c6-51f454b6059a$78f251f6-d35b-422d-92ab-7fabd80bef85$35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf$35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf$3d147a08-00b9-47c7-940a-d75c36a6ce81$78f251f6-d35b-422d-92ab-7fabd80bef85$78f251f6-d35b-422d-92ab-7fabd80bef85$78f251f6-d35b-422d-92ab-7fabd80bef85$35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf$35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf$3a660456-eceb-472b-a9a9-f2a5b0ce972b$3a660456-eceb-472b-a9a9-f2a5b0ce972b$3d147a08-00b9-47c7-940a-d75c36a6ce81$78f251f6-d35b-422d-92ab-7fabd80bef85$f36e2211-8cfd-4813-8618-34e606fe73ac$f36e2211-8cfd-4813-8618-34e606fe73ac tenant_typeString ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION ORGANIZATION client_idStringdevfront
adminfrontdevfrontorgfront
adminfrontdevfrontorgfrontorgfrontdevfrontorgfrontdevfront$37290c73-0e5f-4250-ac4d-7b173d6b6ee0devfront$2ddc94e5-6c0f-4456-a025-1c6f438fb046$2ddc94e5-6c0f-4456-a025-1c6f438fb046
@@ -55,11 +55,14 @@ services:
build:
context: .
dockerfile: ./adminfront/Dockerfile
target: dev
args:
VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: adminfront
container_name: baron_adminfront
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
working_dir: /workspace/adminfront
env_file:
- .env
environment:
@@ -67,6 +70,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
- DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ADMINFRONT_PORT:-5173}:5173"
volumes:
@@ -84,11 +88,14 @@ services:
build:
context: .
dockerfile: ./devfront/Dockerfile
target: dev
args:
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: devfront
container_name: baron_devfront
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
working_dir: /workspace/devfront
env_file:
- .env
environment:
@@ -96,6 +103,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
- DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${DEVFRONT_PORT:-5174}:5173"
volumes:
@@ -113,11 +121,14 @@ services:
build:
context: .
dockerfile: ./orgfront/Dockerfile
target: dev
args:
VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: orgfront
container_name: baron_orgfront
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5175"]
working_dir: /workspace/orgfront
env_file:
- .env
environment:
@@ -125,6 +136,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
- DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ORGFRONT_PORT:-5175}:5175"
volumes:
@@ -1,7 +1,7 @@
{
"format_version": "1",
"created_at": "2026-06-15T10:54:33Z",
"git_commit": "4d468cd39f66",
"created_at": "2026-06-17T12:24:46Z",
"git_commit": "b2808759d22a",
"mode": "maintenance",
"environment_scope": "same-env-only",
"services": ["postgres", "ory-postgres", "clickhouse", "ory-clickhouse", "config"],
@@ -2,7 +2,7 @@
-- PostgreSQL database cluster dump
--
\restrict Nh0reka1aBJKKDfxlc9L4ubRixmPembXVECzqwEVE1GjdnFtzDPBHXZ6jU72Ib3
\restrict 0VWmw0eDD5rSeTkFM3kTkxmXdU53ob4gyB6AWfAtjn8ay3NdNHvQwnlEuVR9eTY
SET default_transaction_read_only = off;
@@ -27,7 +27,7 @@ ALTER ROLE ory WITH SUPERUSER INHERIT CREATEROLE CREATEDB LOGIN REPLICATION BYPA
\unrestrict Nh0reka1aBJKKDfxlc9L4ubRixmPembXVECzqwEVE1GjdnFtzDPBHXZ6jU72Ib3
\unrestrict 0VWmw0eDD5rSeTkFM3kTkxmXdU53ob4gyB6AWfAtjn8ay3NdNHvQwnlEuVR9eTY
--
-- PostgreSQL database cluster dump complete
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
"Name","External Key"
"회장","chair"
"사장","pres"
"부회장","vchair"
"고문","advisor"
"부사장","vp"
"전무이사","execdir"
"상무이사","mandir"
"이사","dir"
"부장","gm"
"차장","dgm"
"과장","mgr"
"대리","asstmgr"
"사원","staff"
1 Name External Key
2 회장 chair
3 사장 pres
4 부회장 vchair
5 고문 advisor
6 부사장 vp
7 전무이사 execdir
8 상무이사 mandir
9 이사 dir
10 부장 gm
11 차장 dgm
12 과장 mgr
13 대리 asstmgr
14 사원 staff
+2
View File
@@ -14,6 +14,7 @@ const reactPackageDir = path.dirname(require.resolve("react/package.json"));
const reactDomPackageDir = path.dirname(
require.resolve("react-dom/package.json"),
);
const usePolling = process.env.DEV_SERVER_WATCH_POLLING === "true";
export const commonViteConfig: UserConfig = {
plugins: [react()],
@@ -33,6 +34,7 @@ export const commonViteConfig: UserConfig = {
fs: {
allow: [appWorkspaceDir, commonWorkspaceDir, "/workspace/common"],
},
watch: usePolling ? { interval: 300, usePolling: true } : undefined,
},
};
+12 -1
View File
@@ -1,4 +1,4 @@
FROM node:lts AS build
FROM node:lts AS deps
WORKDIR /workspace
@@ -20,6 +20,17 @@ ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
RUN pnpm install --frozen-lockfile --ignore-scripts
FROM deps AS dev
WORKDIR /workspace/devfront
ENV NODE_ENV=development
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
FROM deps AS build
WORKDIR /workspace/devfront
RUN npm run build
Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+11 -5
View File
@@ -13,7 +13,12 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
const skipWebServer =
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4174";
const port = Number.parseInt(process.env.PORT ?? "5174", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL =
process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL || defaultBaseUrl;
const usePreviewServer =
process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true";
/**
* Read environment variables from file.
@@ -73,9 +78,10 @@ export default defineConfig({
webServer: skipWebServer
? undefined
: {
command:
"VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 4174",
url: baseURL,
reuseExistingServer: false,
command: usePreviewServer
? `VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port ${port}`
: `VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite --host 127.0.0.1 --strictPort --port ${port}`,
url: defaultBaseUrl,
reuseExistingServer: !process.env.CI && !process.env.PORT,
},
});
+12
View File
@@ -55,11 +55,14 @@ services:
build:
context: .
dockerfile: ./adminfront/Dockerfile
target: dev
args:
VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: adminfront
container_name: baron_adminfront
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
working_dir: /workspace/adminfront
env_file:
- .env
environment:
@@ -67,6 +70,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
- DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ADMINFRONT_PORT:-5173}:5173"
volumes:
@@ -84,11 +88,14 @@ services:
build:
context: .
dockerfile: ./devfront/Dockerfile
target: dev
args:
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: devfront
container_name: baron_devfront
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
working_dir: /workspace/devfront
env_file:
- .env
environment:
@@ -96,6 +103,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
- DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${DEVFRONT_PORT:-5174}:5173"
volumes:
@@ -113,11 +121,14 @@ services:
build:
context: .
dockerfile: ./orgfront/Dockerfile
target: dev
args:
VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: orgfront
container_name: baron_orgfront
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5175"]
working_dir: /workspace/orgfront
env_file:
- .env
environment:
@@ -125,6 +136,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
- DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ORGFRONT_PORT:-5175}:5175"
volumes:
+5 -5
View File
@@ -8,7 +8,7 @@ Baron SSO에서 인증 identity, 권한 관계, OAuth/OIDC 위임의 원장은 O
- Authorization/ReBAC 원장: Ory Keto
- OAuth/OIDC client, consent, token state 원장: Ory Hydra
Backend DB는 Ory를 대체하는 원장이 아닙니다. Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 업무 데이터의 read model, 감사 로그, 처리 상태, 성능 cache 보조 데이터만 허용합니다.
Backend DB는 Ory를 대체하는 원장이 아닙니다. 특히 사용자 identity/profile/소속/조직도 노출 데이터에 대해 Backend DB `users`를 원장 또는 read model로 사용하지 않습니다. Ory와 무관한 감사 로그, 처리 상태, 외부 연동 작업 상태처럼 별도 원장이 명시된 데이터만 Backend DB에 둘 수 있습니다.
Ory에서 Redis cache로 웜업된 데이터는 Backend가 cursor 기반 API로 front 또는 외부 API에 제공합니다. frontend는 Redis나 Backend DB 복제본을 원장처럼 직접 소비하지 않습니다.
@@ -19,7 +19,7 @@ Ory에서 Redis cache로 웜업된 데이터는 Backend가 cursor 기반 API로
- Ory Kratos identity가 subject, credentials, recovery/verification address, 인증 식별자의 원장입니다.
- Kratos identity 변경은 Backend의 중앙 `IdentityWriteService`를 경유해야 합니다.
- Redis identity mirror는 빠른 단건/목록/검색 조회를 위한 cache입니다. stale 가능성을 API 응답에 드러내야 합니다.
- Backend DB `users`Ory에 저장되지 않거나 Ory에서 필요한 방식으로 조회할 수 없는 Baron 운영 데이터의 read model입니다.
- Backend DB `users`사용자 identity/profile/소속 조회 read model이 아닙니다. 남은 의존은 제거 대상이며, 조회 API는 Kratos identity mirror 또는 Kratos Admin API fallback을 기준으로 해야 합니다.
### 2.2 Permissions & Relationships
@@ -41,14 +41,14 @@ Ory에서 Redis cache로 웜업된 데이터는 Backend가 cursor 기반 API로
2. Backend가 중앙 service를 통해 Ory API를 동기 호출합니다.
3. Ory write 성공 후 Ory ID로 재조회합니다.
4. Redis mirror를 갱신하거나 갱신 실패 시 `stale`/`failed` 상태를 기록합니다.
5. Ory에 저장되지 않거나 조회 불가능한 read model만 Backend DB에 갱신합니다.
5. 사용자 identity/profile/소속 데이터는 Backend DB `users`에 read model로 갱신하지 않습니다. Ory와 별도 원장이 명시된 처리 상태만 Backend DB에 기록합니다.
### 3.2 Read Path
- Self context: Ory session/token 또는 Ory API를 기준으로 검증합니다.
- Admin/list context: Backend가 Redis mirror와 허용된 read model을 조합해 cursor 기반 API 제공합니다.
- Admin/list context: Backend가 Redis identity mirror 또는 Ory Admin API fallback을 기준으로 cursor 기반 API 제공합니다.
- API response는 `identityTotal`, read model count, mirror status를 구분해야 합니다.
### 3.3 Conflict Resolution
불일치가 발견되면 Ory Stack의 데이터를 기준으로 Redis mirror와 Backend read model을 보정합니다. Backend read model이나 token claim assembly 결과를 Ory보다 우선하는 근거로 사용하지 않습니다.
불일치가 발견되면 Ory Stack의 데이터를 기준으로 Redis mirror 보정합니다. Backend DB `users`나 token claim assembly 결과를 Ory보다 우선하는 근거로 사용하지 않습니다.
@@ -0,0 +1,110 @@
# Admin User List SSOT, Cache, and Local User DB 제거 정책
작성일: 2026-06-17
## 결론
AdminFront 사용자 목록은 테넌트 목록과 같은 cursor/search 경험을 제공해야 하지만, 사용자 identity의 SSOT는 Ory Kratos입니다. 성능 목표를 위해 cache를 사용할 수 있고 사용해야 하지만, cache나 PostgreSQL `users` table을 사용자 identity/profile/소속 조회의 원장 또는 read model로 사용하면 안 됩니다.
테넌트 목록은 현재 primary DB cursor/search/sort query와 batch aggregation으로 개선되어 있으며, Redis cache는 필수 구현으로 들어가지 않았습니다. 다만 #1191의 정책처럼 Redis cache는 tenant tree edge, scope 계산, page response, member count aggregate 같은 보조 성능 계층으로 허용됩니다.
사용자 목록은 데이터 원장이 Kratos이고 목록 조회가 Redis identity mirror를 거치므로, 목표 수치에 도달하려면 단순 KV mirror 전체 scan이 아니라 cursor/search/scope에 맞는 Redis index cache가 필요합니다.
## 사용자 목록 SSOT 원칙
- Kratos `identities`가 subject, credentials, recovery/verifiable address, identity state의 원장입니다.
- Redis identity mirror/index는 Kratos identity를 빠르게 조회하기 위한 cache입니다.
- Backend DB `users`는 Kratos를 대체하는 사용자 원장도, 사용자 조회 read model도 아닙니다.
- 사용자 목록 API는 Kratos에서 warm-up된 Redis mirror를 기준으로 응답해야 하며, identity 존재 여부와 identity total은 Kratos mirror 기준을 우선해야 합니다.
- Redis mirror가 stale/failed/empty이면 이를 숨기지 말고 API 응답 또는 운영 상태에 드러내야 합니다.
- cache miss 또는 drift가 발견되면 Kratos 기준으로 Redis mirror를 보정해야 하며, local `users` DB를 정상 fallback처럼 사용하면 안 됩니다.
## Cache 사용 가능 범위
허용되는 cache:
- `identity:mirror:{identityID}`: Kratos identity summary JSON
- `identity:mirror:state`: mirror status, observed count, refreshed time, error
- sorted/index set: `createdAt,id` 기반 cursor page 조회
- normalized search token index: email, name, login ID, phone, selected metadata search
- tenant access key index: primary tenant, joined tenant, additional appointment tenant id/slug
- short TTL page response cache: role/scope/search/tenantSlug/sort/direction/cursor/limit 포함 key
제약:
- cache key는 query scope를 모두 포함해야 합니다.
- 권한 범위가 들어간 cache는 user id 또는 permission scope hash를 포함해야 합니다.
- Kratos write-through 실패 시 mirror state를 `stale` 또는 `failed`로 전환해야 합니다.
- Redis 장애 시 local DB를 identity SSOT fallback처럼 사용하지 않습니다. 가능한 경우 Kratos API fallback을 쓰고, 불가능하면 identity mirror unavailable로 실패시킵니다.
- cache는 감사 근거 또는 권한 판정 단독 근거가 아닙니다. 권한 관계의 원장은 Keto입니다.
## 현재 사용자 목록 병목
현재 `GET /v1/admin/users``listIdentitiesFromMirrorOrKratos()`를 통해 identity mirror 전체를 배열로 읽은 다음 handler 메모리에서 검색, tenant filter, 권한 scope, sort, pagination을 수행합니다.
Redis mirror 구현도 `SCAN identity:mirror:*` 후 key별 `GET`으로 전체 identity를 materialize합니다. 따라서 API 응답은 cursor를 반환하지만, 서버 내부 비용은 page 단위가 아니라 전체 mirror 크기에 비례합니다.
테넌트 목록과 같은 성능 특성을 만들려면 `/v1/admin/users`의 cursor/search/scope 조건이 Redis identity index 또는 Kratos-backed query boundary까지 내려가야 합니다.
## Local `users` DB 잔존 의존 제거 대상
PostgreSQL `users` table은 admin user list, org-context, orgfront snapshot의 사용자 identity/profile/소속 read model로 사용하면 안 됩니다. 현재 코드 기준으로 남아 있는 의존은 유지 근거가 아니라 제거 또는 별도 원장 재정의가 필요한 대상입니다.
| 기능 | 사용 위치 | 정리 방향 |
| --- | --- | --- |
| 사용자 생성/수정 후 local sync | `user_handler.go`, `auth_handler.go` | Kratos write-through 후 Redis mirror 갱신으로 대체하고 local sync 제거 여부를 추적합니다. |
| custom login ID index | `user_login_ids`, `IsLoginIDTaken`, `FindTenantIDByLoginID` | Kratos traits/credentials identifier 또는 별도 명시 원장으로 재정의합니다. |
| tenant membership count | `TenantHandler.countTenantMembers`, `UserRepo.CountByTenantIDs` | Redis identity mirror/Keto relation 기준 aggregate로 대체합니다. |
| org context member export | `TenantHandler.loadOrgContextMembers` | Kratos identity mirror 기준으로 전환합니다. |
| admin CSV export/import | `ExportUsersCSV`, bulk create/update | Kratos/mirror 기반 export와 command import로 분리합니다. |
| WORKS Mobile sync/comparison | `worksmobile_sync_service.go` | Kratos identity mirror와 WORKS API 비교로 전환합니다. |
| Hanmac email/local-part policy | `hanmac_email_policy.go` | Kratos identifier/mirror index 기준 uniqueness로 전환합니다. |
| user status 운영 정책 | `users.status`, `deleted_at` | Kratos traits/state 또는 명시된 별도 원장으로 재정의합니다. |
| profile/session 보조 | `auth_handler.go` | Kratos session/identity traits와 Redis mirror 기준으로 전환합니다. |
따라서 local `users` DB를 사용자 조회의 primary source 또는 read model로 유지하는 방향은 최신 정책과 맞지 않습니다.
## 사용자 목록 개선 방향
1. Redis identity mirror index를 확장합니다.
- 기본 정렬: `createdAt desc, id desc`
- cursor: timestamp + identity id
- search: normalized email/name/login ID/phone/token index
- tenant filter: primary tenant id/slug와 additional appointments 기반 index
2. `/v1/admin/users` query boundary를 분리합니다.
- 입력: `limit`, `cursor`, `search`, `tenantSlug`, `sort`, `direction`, requester scope
- 출력: page identities, `identityTotal`, `nextCursor`, `mirrorStatus`
- handler는 전체 identity slice를 직접 만들지 않습니다.
3. row enrichment는 page 단위 batch로 수행합니다.
- primary tenant summary lookup batch
- joined tenant/relation lookup batch 또는 bounded cache
- per-row `GetTenant`/`ListJoinedTenants` 반복 호출 금지
4. local `users` DB join을 제거합니다.
- 사용자 identity/profile/소속/상태는 Kratos mirror 기준으로 응답
- Kratos mirror에 없는 local DB row를 목록 identity로 승격하지 않음
- 남은 local DB 의존은 drift/deprecation report에만 노출
5. AdminFront는 tenant 목록과 같은 query lifecycle로 맞춥니다.
- `useInfiniteQuery` queryKey에 normalized/deferred search, tenantSlug, sort, direction 포함
- 검색/정렬 변경 시 cursor를 재시작
- tenant filter option 로딩은 사용자 첫 page 렌더를 막지 않음
## 테스트 기준
RED 테스트는 다음 실패를 먼저 보여야 합니다.
- search/cursor 요청에서 backend가 전체 mirror를 매번 materialize하는 경로가 호출됨
- 첫 page 밖 사용자가 검색되어야 하지만 cursor/search/index 계약이 없어서 전체 scan에 의존함
- page item 50개 매핑 시 tenant lookup 또는 joined tenant lookup이 row 수만큼 반복됨
- Redis mirror stale 상태에서 local DB만으로 정상 목록처럼 응답함
최종 통과 기준:
- Kratos mirror 기준 identity result와 API `items`가 동일합니다.
- Redis index cache hit/miss가 같은 결과를 냅니다.
- Redis stale/failed 상태가 숨겨지지 않습니다.
- local DB row만 있고 Kratos mirror에 없는 사용자는 일반 admin user list에 identity item으로 나오지 않습니다.
- 3,500건 이상 사용자 데이터에서 첫 화면 1500ms 이하, 검색 500ms 이하를 유지합니다.
+1 -1
View File
@@ -2,7 +2,7 @@
## 목적
외부 연동앱이 계정 세션 없이 M2M 방식으로 Baron SSO의 조직구성을 조회할 수 있게 한다. 조직구성 Ory SSOT에서 웜업한 Redis cache Ory에 저장되지 않거나 조회가 불가능한 Backend read model을 Backend가 조합해 cursor 기반 API로 제공한다. Backend DB나 claim output을 SSOT로 사용하지 않으며, iframe 또는 `postMessage` 계약은 사용하지 않는다.
외부 연동앱이 계정 세션 없이 M2M 방식으로 Baron SSO의 조직구성을 조회할 수 있게 한다. 조직구성과 사용자 멤버 정보는 Ory SSOT에서 웜업한 Redis cache 또는 Ory Admin API fallback을 기준으로 제공한다. Backend DB `users`나 claim output을 SSOT 또는 read model로 사용하지 않으며, iframe 또는 `postMessage` 계약은 사용하지 않는다.
## 인증
+78
View File
@@ -0,0 +1,78 @@
# make dev Vite HMR Policy
`make dev`는 로컬 개발 중 소스 변경이 즉시 화면에 반영되는 환경이어야 합니다. `adminfront`, `devfront`, `orgfront``make dev`에서 production `dist`를 정적 서빙하지 않고 Vite dev server로 실행합니다.
## Policy
- `make dev`로 실행되는 React frontends는 Vite HMR 모드여야 합니다.
- 대상 서비스는 `adminfront`, `devfront`, `orgfront`입니다.
- 각 서비스는 Docker Compose에서 해당 Dockerfile의 `dev` target으로 빌드해야 합니다.
- 각 서비스의 working directory는 bind-mounted source path인 `/workspace/<app>`이어야 합니다.
- 각 서비스 command는 `npm run dev -- --host 0.0.0.0 --port <port>` 형태여야 합니다.
- Docker bind mount 환경에서 파일 변경 감지가 누락되지 않도록 `DEV_SERVER_WATCH_POLLING=true`가 기본값이어야 합니다.
- production image는 기존처럼 `production` target에서 build output `dist``serve_frontend_prod.mjs`로 정적 서빙할 수 있습니다. 이 정책은 `make dev` runtime에만 적용합니다.
## Current Ports
- AdminFront: `http://localhost:5173`
- DevFront: `http://localhost:5174`
- OrgFront: `http://localhost:5175`
## Required Structure
Each React frontend Dockerfile must keep these stages:
- `deps`: install workspace dependencies.
- `dev`: run Vite dev server from `/workspace/<app>`.
- `build`: run production build.
- `production`: serve built `dist` with the static server.
`docker-compose.yaml` must select `target: dev` for the three React frontends. This prevents `make dev` from accidentally serving stale `/app/dist` output.
## Regression Guard
The policy is checked by:
```sh
test/frontend_dev_bind_mount_policy_test.sh
test/playwright_frontend_runtime_policy_test.sh
```
The test verifies that:
- source directories are bind-mounted into `/workspace/<app>`;
- `node_modules` stays in container volumes;
- each React frontend uses `target: dev`;
- each React frontend runs `npm run dev`;
- each React frontend enables polling watch by default;
- `serve_frontend_prod.mjs` is not used by the Compose dev service;
- Dockerfiles keep separate `dev`, `build`, and `production` stages.
If this test fails, do not work around it by refreshing the browser or rebuilding manually. Fix the Compose/Dockerfile runtime so `make dev` remains a true HMR development mode.
## Playwright Runtime Policy
Local Playwright tests should use Vite dev servers by default. This keeps local E2E feedback aligned with `make dev` and avoids testing stale production `dist` output while actively editing frontend code.
Gitea Actions and other CI runs should use build output through Vite preview. This validates production bundle behavior before merge.
Required behavior:
- Local default:
- AdminFront uses Vite dev server on port `5173`.
- DevFront uses Vite dev server on port `5174`.
- OrgFront uses Vite dev server on port `5175`.
- CI or explicit preview mode:
- `CI=true` or `PLAYWRIGHT_USE_PREVIEW=true` switches React frontend Playwright web servers to `build` + `preview`.
- Existing running server mode:
- `BASE_URL` can be used for AdminFront and OrgFront.
- `PLAYWRIGHT_BASE_URL` or `BASE_URL` can be used for DevFront.
- `PLAYWRIGHT_SKIP_WEBSERVER=true` disables DevFront's managed web server.
Use explicit preview locally only when testing production bundle behavior:
```sh
PLAYWRIGHT_USE_PREVIEW=true npm --prefix adminfront test
PLAYWRIGHT_USE_PREVIEW=true npm --prefix devfront test
PLAYWRIGHT_USE_PREVIEW=true npm --prefix orgfront test
```
+38
View File
@@ -0,0 +1,38 @@
# Worksmobile Sync Policy
## 겸직 소속 비교 정책
Baron과 WORKS 모두 한 사용자가 여러 조직에 동시에 소속될 수 있다. 따라서 사용자 소속 비교는 배열 순서나 대표 조직 우선순위만으로 불일치를 판단하지 않는다.
### Membership Set 우선
사용자 소속 비교의 기본 단위는 `domainID + orgUnitID`로 구성한 membership set이다.
- Baron expected organization과 WORKS remote organization의 membership set이 같으면 같은 소속으로 본다.
- 같은 membership set 안에서 `organization.primary` 또는 `orgUnit.primary` 우선순위만 다른 경우는 보정 사유가 아니다.
- GPDTDC와 본소속이 모두 있는 사용자도 같은 규칙을 적용한다.
### 보정 대상
다음 차이는 계속 보정 대상이다.
- Baron에는 있는데 WORKS에 없는 orgUnit membership
- WORKS에는 있는데 Baron에는 없는 orgUnit membership
- 비교 대상 position 값 차이
- 비교 대상 manager 값 차이
### 보정 제외
다음 차이는 사용자 `organization` update reason으로 만들지 않는다.
- 같은 membership set에서 GPDTDC와 본소속의 primary 우선순위만 다른 경우
- 같은 membership set에서 WORKS의 조직 배열 순서만 다른 경우
## Grade 비교 정책
직급(`grade`) 차이는 WORKS 사용자 보정 대상이다. 단, 비교 기준은 사용자 전역 `user.grade`가 아니라 테넌트 소속 정보에 연결된 `additionalAppointments[].grade`이다.
- Baron의 테넌트 소속별 `grade`는 같은 WORKS `orgUnit` membership이 속한 organization level과 비교한다.
- 같은 membership에서 Baron 테넌트 소속 `grade`와 WORKS organization level이 다르거나 WORKS level이 비어 있으면 `grade` update reason으로 본다.
- GPDTDC 산하 테넌트의 연구원 직급과 그 외 테넌트의 일반 직급은 직급체계가 다르므로 서로 교차 비교하지 않는다.
- GPDTDC와 본소속을 모두 가진 사용자는 각 membership의 테넌트 소속 `grade`와 해당 WORKS organization level만 각각 비교한다.
+8
View File
@@ -0,0 +1,8 @@
"Name","External Key"
"사장","pres"
"부사장","vp"
"전무이사","execdir"
"수석 연구원","prin"
"책임 연구원","lead"
"선임 연구원","sen"
"연구원","res"
1 Name External Key
2 사장 pres
3 부사장 vp
4 전무이사 execdir
5 수석 연구원 prin
6 책임 연구원 lead
7 선임 연구원 sen
8 연구원 res
+12 -1
View File
@@ -1,4 +1,4 @@
FROM node:lts AS build
FROM node:lts AS deps
WORKDIR /workspace
@@ -20,6 +20,17 @@ ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
RUN pnpm install --frozen-lockfile --ignore-scripts
FROM deps AS dev
WORKDIR /workspace/orgfront
ENV NODE_ENV=development
EXPOSE 5175
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5175"]
FROM deps AS build
WORKDIR /workspace/orgfront
RUN npm run build
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+4 -2
View File
@@ -10,11 +10,13 @@ const { shouldIncludeWebKit } =
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
const port = Number.parseInt(process.env.PORT ?? "4175", 10);
const port = Number.parseInt(process.env.PORT ?? "5175", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI && !process.env.PORT;
const testOidcAuthority = "http://localhost:5000/oidc";
const usePreviewServer =
process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true";
/**
* Read environment variables from file.
@@ -79,7 +81,7 @@ export default defineConfig({
webServer: process.env.BASE_URL
? undefined
: {
command: process.env.CI
command: usePreviewServer
? `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run build && VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run preview -- --host 127.0.0.1 --port ${port}`
: `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run dev -- --host 127.0.0.1 --port ${port}`,
url: defaultBaseUrl,
@@ -0,0 +1,41 @@
import type { OrgChartSnapshotResponse } from "../../lib/adminApi";
export function resolveOrgChartSnapshotTimestamp(
snapshot?: Pick<
OrgChartSnapshotResponse,
"generatedAt" | "tenants" | "users"
>,
) {
if (!snapshot) return "";
if (snapshot.generatedAt?.trim()) return snapshot.generatedAt.trim();
const timestamps = [
...snapshot.tenants.map((tenant) => tenant.updatedAt),
...snapshot.users.map((user) => user.updatedAt),
]
.map((value) => Date.parse(value))
.filter((value) => Number.isFinite(value));
if (timestamps.length === 0) return "";
return new Date(Math.max(...timestamps)).toISOString();
}
export function formatOrgChartSnapshotTimestamp(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((item) => item.type === type)?.value ?? "";
return `${part("year")}-${part("month")}-${part("day")} ${part("hour")}:${part("minute")}:${part("second")} KST`;
}
@@ -114,6 +114,32 @@ describe("buildOrgPickerTree", () => {
]);
});
it("can expose every visible top-level tenant as picker roots", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant("commercial-id", "COMPANY_GROUP", "Commercial", "commercial"),
tenant(
"external-company-id",
"COMPANY",
"외부기업",
"external-company",
"commercial-id",
),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
rootTenantId: "all",
});
expect(tree.roots.map((node) => node.id)).toEqual([
"hanmac-family-id",
"commercial-id",
]);
});
it("excludes internal and private tenants from picker choices by default", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
@@ -4,12 +4,14 @@ import {
buildUsersMap,
clampScale,
filterSystemGlobalTenants,
formatOrgChartSnapshotTimestamp,
getMemberGridMetrics,
getOrgNodeHeaderFill,
getSemanticZoomMode,
layoutForest,
type OrgNode,
resolveOrgChartFamilyRoot,
resolveOrgChartSnapshotTimestamp,
} from "./OrgChartPage";
function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode {
@@ -255,6 +257,94 @@ describe("org chart layout", () => {
]);
});
it("places organization owners before higher rank members in the same organization", () => {
const members = [
{ ...member("executive"), name: "임원", grade: "전무이사" },
{
...member("owner"),
name: "조직장",
grade: "사원",
metadata: {
additionalAppointments: [
{
tenantSlug: "root",
isOwner: true,
},
],
},
},
{ ...member("principal"), name: "수석", grade: "수석연구원" },
];
const layout = layoutForest(
[
{
...orgNode("root"),
members,
totalCount: members.length,
totalMemberIds: new Set(members.map((item) => item.id)),
},
],
new Set(),
);
const rootNode = layout.nodes.find((item) => item.node.id === "root");
expect(rootNode?.members.map((item) => item.id)).toEqual([
"owner",
"executive",
"principal",
]);
});
it("does not place representative organization members before leaders", () => {
const members = [
{ ...member("executive"), name: "임원", grade: "전무이사" },
{
...member("representative"),
name: "대표조직장",
grade: "사원",
metadata: {
additionalAppointments: [
{
tenantSlug: "root",
representative: true,
},
],
},
},
{
...member("team-head"),
name: "팀장",
grade: "선임",
metadata: {
additionalAppointments: [
{
tenantSlug: "root",
position: "팀장",
},
],
},
},
];
const layout = layoutForest(
[
{
...orgNode("root"),
members,
totalCount: members.length,
totalMemberIds: new Set(members.map((item) => item.id)),
},
],
new Set(),
);
const rootNode = layout.nodes.find((item) => item.node.id === "root");
expect(rootNode?.members.map((item) => item.id)).toEqual([
"team-head",
"executive",
"representative",
]);
});
it("expands node width for long organization names even without members", () => {
const shortLayout = layoutForest([orgNode("short")], new Set());
const longLayout = layoutForest(
@@ -272,6 +362,7 @@ describe("org chart layout", () => {
const longNode = longLayout.nodes.find((item) => item.node.id === "long");
expect(longNode?.width).toBeGreaterThan(shortNode?.width ?? 0);
expect(longNode?.width).toBeGreaterThan(640);
});
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
@@ -356,7 +447,7 @@ describe("org chart layout", () => {
expect(rootEdges.filter((edge) => edge.visibleByDefault)).toHaveLength(3);
});
it("places the deepest child subtree in the first multi-column section", () => {
it("preserves source data order in multi-column sections", () => {
const children = [
orgNode("shallow-1", [], 1),
orgNode("shallow-2", [], 1),
@@ -378,11 +469,14 @@ describe("org chart layout", () => {
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const rootEdges = layout.edges.filter((edge) =>
edge.key.startsWith("root->"),
);
const directChildren = layout.nodes
.filter((node) => node.node.level === 1)
.sort((a, b) => a.y - b.y || a.x - b.x)
.map((node) => node.node.id);
expect(rootEdges.map((edge) => edge.key)).toContain("root->deep");
expect(directChildren.slice(0, children.length)).toEqual(
children.map((child) => child.id),
);
});
it("centers a parent over the full child span in multi-column mode", () => {
@@ -491,6 +585,24 @@ describe("org chart layout", () => {
expect(getSemanticZoomMode(0.8)).toBe("detail");
});
it("uses the snapshot generatedAt as the org chart data timestamp", () => {
expect(
resolveOrgChartSnapshotTimestamp({
generatedAt: "2026-06-17T07:10:11Z",
tenants: [tenantNode("tenant", "COMPANY", "Tenant", "tenant")],
users: [
{
...member("user"),
updatedAt: "2026-06-17T09:10:11Z",
},
],
}),
).toBe("2026-06-17T07:10:11Z");
expect(formatOrgChartSnapshotTimestamp("2026-06-17T07:10:11Z")).toBe(
"2026-06-17 16:10:11 KST",
);
});
it("uses distinct header fills by organization depth", () => {
expect(getOrgNodeHeaderFill(0, "family")).toBe("#000000");
expect(getOrgNodeHeaderFill(0, "saman")).toBe("#f58220");
@@ -606,13 +718,13 @@ describe("org chart layout", () => {
]);
});
it("maps legacy companyCode users to matching tenant slugs", () => {
it("does not map scalar-only tenant slugs without a membership source", () => {
const usersMap = buildUsersMap(
[
{
...member("engineering-user"),
...member("scalar-only-user"),
companyCode: "engineering",
tenantSlug: undefined,
tenantSlug: "engineering",
tenant: undefined,
joinedTenants: undefined,
},
@@ -621,8 +733,41 @@ describe("org chart layout", () => {
{ activeOnly: true },
);
expect(usersMap.get("engineering")?.map((user) => user.id)).toEqual([
"engineering-user",
expect(usersMap.get("engineering")).toBeUndefined();
});
it("maps super admin users when they have an actual org chart appointment", () => {
const usersMap = buildUsersMap(
[
{
...member("super-admin-leader"),
role: "super_admin",
companyCode: "is-1",
tenantSlug: "is-2",
tenant: undefined,
joinedTenants: undefined,
metadata: {
additionalAppointments: [
{
tenantSlug: "is-3",
isManager: true,
},
],
},
},
],
[
tenantNode("is-1", "ORGANIZATION", "IS1", "is-1"),
tenantNode("is-2", "ORGANIZATION", "IS2", "is-2"),
tenantNode("is-3", "ORGANIZATION", "IS3", "is-3"),
],
{ activeOnly: true },
);
expect(usersMap.get("is-1")).toBeUndefined();
expect(usersMap.get("is-2")).toBeUndefined();
expect(usersMap.get("is-3")?.map((user) => user.id)).toEqual([
"super-admin-leader",
]);
});
@@ -18,6 +18,11 @@ import { getOrgRankWeight } from "../rankPriority";
import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility";
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
export {
formatOrgChartSnapshotTimestamp,
resolveOrgChartSnapshotTimestamp,
} from "../orgChartSnapshotTime";
export type OrgNode = {
id: string;
name: string;
@@ -105,7 +110,6 @@ const MEMBER_CARD_CHAR_WIDTH = 12;
const MEMBER_CARD_TEXT_PADDING_X = 28;
const MEMBER_COLUMN_MAX_WIDTH = 280;
const NODE_HEADER_CHAR_WIDTH = 17;
const NODE_HEADER_WRAP_THRESHOLD = 420;
const NODE_HEADER_TEXT_PADDING_X = 112;
const NODE_HEADER_TYPE_BADGE_WIDTH = 48;
const MEMBER_GRID_TARGET_ASPECT_RATIO = 2;
@@ -162,7 +166,7 @@ function getManagerWeight(
user: UserSummary,
tenant?: { id: string; slug: string },
) {
return getUserOrgProfile(user, tenant).isManager ? 0 : 1;
return getUserOrgProfile(user, tenant).leadershipWeight;
}
function compareOrgMembers(
@@ -272,14 +276,9 @@ function getNodeHeaderWidth(node: OrgNode) {
const typeBadgeWidth = node.orgUnitType ? NODE_HEADER_TYPE_BADGE_WIDTH : 0;
const titleTextWidth =
getDisplayTextWidthUnit(node.name) * NODE_HEADER_CHAR_WIDTH;
const oneLineWidth =
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth;
if (oneLineWidth <= NODE_HEADER_WRAP_THRESHOLD) {
return Math.ceil(oneLineWidth);
}
const estimatedTitleWidth =
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth / 2;
return Math.ceil(estimatedTitleWidth);
return Math.ceil(
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth,
);
}
function getNodeWidth(
@@ -562,19 +561,8 @@ function getRowOffsets(heights: number[], gap: number) {
return offsets;
}
function getLayoutMaxDepth(layout: ChartLayout) {
return Math.max(...layout.nodes.map((visualNode) => visualNode.node.level));
}
function orderChildLayoutsForMultiColumn(childLayouts: ChartLayout[]) {
return childLayouts
.map((layout, index) => ({
depth: getLayoutMaxDepth(layout),
index,
layout,
}))
.sort((a, b) => b.depth - a.depth || a.index - b.index)
.map((entry) => entry.layout);
return childLayouts;
}
function rangesOverlap(
@@ -1109,20 +1097,7 @@ function isSystemGlobalTenant(
}
function isSystemGlobalUser(user: UserSummary) {
const normalizedRole = user.role.toLowerCase().replaceAll("_", "-");
return (
normalizedRole === "super-admin" ||
normalizedRole === "superadmin" ||
normalizedRole === "system-admin" ||
isSystemGlobalTenant(user.tenant) ||
isSystemGlobalTenant({
id: user.tenantSlug || "",
slug: user.tenantSlug || "",
type: user.role,
name: user.role,
})
);
return isSystemGlobalTenant(user.tenant);
}
function findNodeByTenantId(
@@ -1390,34 +1365,6 @@ export function buildUsersMap(
if (!isVisibleOrgChartUser(user)) continue;
const tenantIds = new Set<string>();
const primarySlug = normalizeOrgSlug(user.tenantSlug);
const legacyCompanySlug = normalizeOrgSlug(user.companyCode);
if (
primarySlug &&
!isSystemGlobalTenant({
id: primarySlug,
slug: primarySlug,
type: primarySlug,
name: primarySlug,
})
) {
addTenantSlugCandidate(tenantIds, membershipTenantIndexes, primarySlug);
}
if (
legacyCompanySlug &&
!isSystemGlobalTenant({
id: legacyCompanySlug,
slug: legacyCompanySlug,
type: legacyCompanySlug,
name: legacyCompanySlug,
})
) {
addTenantSlugCandidate(
tenantIds,
membershipTenantIndexes,
legacyCompanySlug,
);
}
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
addTenantIdCandidate(tenantIds, membershipTenantIndexes, user.tenant.id);
addTenantSlugCandidate(
@@ -1607,7 +1554,6 @@ export function TenantOrgChartPage() {
() => getOrgSelectionLabel(familyRoot, selectedTenantFilter) ?? "한맥가족",
[familyRoot, selectedTenantFilter],
);
React.useEffect(() => {
if (!tenantId) return;
const searchRoots = familyRoot ? [familyRoot] : rootNodes;
@@ -1800,7 +1746,7 @@ export function TenantOrgChartPage() {
data-testid="orgchart-dashboard-shell"
>
<header className="z-20 grid shrink-0 grid-cols-1 items-center gap-3 border-b border-[#f2c484]/30 bg-[linear-gradient(145deg,rgba(10,42,34,0.98)_0%,rgba(15,58,47,0.98)_52%,rgba(26,86,69,0.98)_100%)] px-6 py-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
<div className="flex min-w-0 justify-start">
<div className="flex min-w-0 flex-wrap items-start justify-start gap-2">
<OrgSelectionPicker
onChange={updateSelectedOrg}
options={orgSelectionOptions}
@@ -1,5 +1,12 @@
import { GitBranch, Network, PanelTop } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { GitBranch, Network, PanelTop, RefreshCw } from "lucide-react";
import * as React from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { fetchOrgChartSnapshot } from "../../../lib/adminApi";
import {
formatOrgChartSnapshotTimestamp,
resolveOrgChartSnapshotTimestamp,
} from "../orgChartSnapshotTime";
const navItems = [
{ to: "/chart", label: "조직도", icon: Network },
@@ -7,6 +14,59 @@ const navItems = [
{ to: "/embed-preview", label: "임베딩 검증", icon: PanelTop },
];
function OrgFrontSnapshotStatus() {
const queryClient = useQueryClient();
const [isRefreshingSnapshot, setIsRefreshingSnapshot] = React.useState(false);
const snapshotQuery = useQuery({
queryKey: ["orgchart-snapshot", { cache: "redis" }],
queryFn: () => fetchOrgChartSnapshot(),
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
const timestamp = React.useMemo(() => {
const value = resolveOrgChartSnapshotTimestamp(snapshotQuery.data);
return value ? formatOrgChartSnapshotTimestamp(value) : "";
}, [snapshotQuery.data]);
const isRefreshing = snapshotQuery.isFetching || isRefreshingSnapshot;
const refreshSnapshot = React.useCallback(() => {
setIsRefreshingSnapshot(true);
void fetchOrgChartSnapshot({ refresh: true })
.then((snapshot) => {
queryClient.setQueryData(
["orgchart-snapshot", { cache: "redis" }],
snapshot,
);
})
.finally(() => setIsRefreshingSnapshot(false));
}, [queryClient]);
return (
<div
className="flex h-7 min-w-[190px] max-w-full shrink-0 items-center justify-end gap-1.5 bg-transparent text-muted-foreground opacity-[0.72]"
data-testid="orgchart-render-status-panel"
>
<p
className="min-w-0 flex-1 truncate text-right text-[11px] font-bold"
data-testid="orgchart-data-timestamp"
>
{timestamp || "-"}
</p>
<button
aria-label="새로고침"
className="inline-flex h-6 w-6 shrink-0 appearance-none items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground shadow-none transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isRefreshing}
onClick={refreshSnapshot}
title="새로고침"
type="button"
>
<RefreshCw className={isRefreshing ? "animate-spin" : ""} size={14} />
</button>
</div>
);
}
export function OrgFrontLayout() {
const location = useLocation();
const isChartRoute =
@@ -24,32 +84,38 @@ export function OrgFrontLayout() {
className="sticky top-0 z-30 shrink-0 border-b border-border bg-background/95 backdrop-blur"
data-testid="orgfront-topbar"
>
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div className="flex w-full flex-col gap-3 px-4 pb-1 pt-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Baron Orgfront
</p>
<h1 className="text-xl font-semibold"> </h1>
</div>
<nav className="flex flex-wrap gap-2" aria-label="주요 메뉴">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-semibold transition",
isActive
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:text-foreground",
].join(" ")
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
<div
className="flex min-w-0 flex-wrap items-end justify-end gap-2 md:self-end"
data-testid="orgfront-topbar-actions"
>
<nav className="flex flex-wrap gap-2" aria-label="주요 메뉴">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-semibold transition",
isActive
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:text-foreground",
].join(" ")
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
<OrgFrontSnapshotStatus />
</div>
</div>
</header>
@@ -274,6 +274,8 @@ describe("OrgPickerEmbedPage orgchart data source", () => {
id: "user-1",
name: "Snapshot User",
email: "user-1@example.com",
rootTenantName: "Hanmac Family",
leafTenantName: "Snapshot Company",
},
],
},
@@ -101,6 +101,25 @@ describe("getOrgChartUserDisplayName", () => {
),
).toBe("홍길동 책임");
});
it("does not fall back to the user grade for a tenant-bound display", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "책임",
metadata: {
additionalAppointments: [
{
tenantId: "tenant-1",
tenantSlug: "hanmac",
},
],
},
}),
{ id: "tenant-1", slug: "hanmac" },
),
).toBe("홍길동");
});
});
describe("getUserOrgProfile", () => {
@@ -164,4 +183,24 @@ describe("getUserOrgProfile", () => {
false,
);
});
it("does not treat representative organization membership as an organization leader", () => {
const profile = getUserOrgProfile(
user({
metadata: {
additionalAppointments: [
{
tenantSlug: "hanmac",
representative: true,
position: "팀원",
},
],
},
}),
{ id: "tenant-1", slug: "hanmac" },
);
expect(profile.isLeader).toBe(false);
expect(profile.isHighlighted).toBe(false);
});
});
+44 -7
View File
@@ -7,6 +7,8 @@ type UserAppointment = {
isAdmin?: boolean;
isManager?: boolean;
isOwner?: boolean;
isRepresentative?: boolean;
representative?: boolean;
grade?: string;
jobTitle?: string;
position?: string;
@@ -33,12 +35,39 @@ function getUserAppointments(user: UserSummary): UserAppointment[] {
isAdmin: item.isAdmin === true,
isManager: item.isManager === true,
isOwner: item.isOwner === true,
isRepresentative: item.isRepresentative === true,
representative: item.representative === true,
grade: normalizeText(item.grade),
jobTitle: normalizeText(item.jobTitle),
position: normalizeText(item.position),
}));
}
function isOrganizationLeaderPosition(position: string) {
const normalized = position.replace(/\s+/g, "");
if (!normalized) return false;
return [
"센터장",
"그룹장",
"본부장",
"실장",
"부문장",
"부서장",
"팀장",
"파트장",
"셀장",
"디비전장",
"디비젼장",
"division장",
"divisionhead",
"manager",
"leader",
"lead",
"head",
].some((keyword) => normalized.toLowerCase().includes(keyword));
}
export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
const appointment = getUserAppointments(user).find((item) => {
if (tenant?.id && item.tenantId === tenant.id) return true;
@@ -51,16 +80,24 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
}
return false;
});
const grade =
tenant && appointment ? appointment.grade : normalizeText(user.grade);
const position = appointment?.position || normalizeText(user.position);
const hasExplicitLeaderFlag =
appointment?.isManager === true || appointment?.isOwner === true;
const hasLeaderPosition = isOrganizationLeaderPosition(position);
const isLeader = hasExplicitLeaderFlag || hasLeaderPosition;
return {
grade: appointment?.grade || normalizeText(user.grade),
isHighlighted:
appointment?.isAdmin === true ||
appointment?.isManager === true ||
appointment?.isOwner === true,
isManager: appointment?.isManager === true,
grade,
isHighlighted: appointment?.isAdmin === true || isLeader,
isLeader,
leadershipWeight: hasExplicitLeaderFlag ? 0 : hasLeaderPosition ? 1 : 2,
isManager: isLeader,
isOwner: appointment?.isOwner === true,
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
position: appointment?.position || normalizeText(user.position),
position,
};
}

Some files were not shown because too many files have changed in this diff Show More