From 6d3f1282824430c419902dcdd20c3696267d838c Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 4 Jun 2026 14:08:55 +0900 Subject: [PATCH] perf(admin): implement server-side search and virtualization for tenant list - Backend: Added 'search' parameter to TenantRepository and TenantService. - Backend: Updated all Tenant list calls to support searching. - Backend: Enhanced UserRepository.List to support cursor-based pagination and search. - Frontend: Switched TenantListPage to use useInfiniteQuery for lazy loading. - Frontend: Implemented list virtualization in TenantHierarchyView using @tanstack/react-virtual. - Frontend: Added server-side search with debouncing (useDeferredValue). - Fixed various Go compilation errors caused by method signature changes. --- .../tenants/routes/TenantListPage.tsx | 216 +++++++++++------- adminfront/src/lib/adminApi.ts | 3 +- backend/internal/handler/admin_handler.go | 2 +- backend/internal/handler/auth_handler.go | 2 +- backend/internal/handler/dev_handler.go | 2 +- .../internal/handler/hanmac_email_policy.go | 2 +- backend/internal/handler/tenant_handler.go | 24 +- backend/internal/handler/user_handler.go | 4 +- .../internal/repository/tenant_repository.go | 9 +- .../internal/repository/user_repository.go | 33 ++- backend/internal/service/tenant_service.go | 6 +- .../service/worksmobile_sync_service.go | 2 +- tenant_mh_manager.csv | 2 + test_users_bulk_comprehensive.csv | 5 + test_users_bulk_final_check.csv | 5 + test_users_bulk_reimport_fix.csv | 5 + test_users_bulk_uuid.csv | 5 + test_users_bulk_with_n_secondary_emails.csv | 4 + 18 files changed, 223 insertions(+), 108 deletions(-) create mode 100644 tenant_mh_manager.csv create mode 100644 test_users_bulk_comprehensive.csv create mode 100644 test_users_bulk_final_check.csv create mode 100644 test_users_bulk_reimport_fix.csv create mode 100644 test_users_bulk_uuid.csv create mode 100644 test_users_bulk_with_n_secondary_emails.csv diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 88151c11..18d29eca 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; import type { AxiosError } from "axios"; import { ArrowDown, @@ -93,6 +94,7 @@ import { import { t } from "../../../lib/i18n"; import { normalizeAdminRole } from "../../../lib/roles"; import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree"; +import { cn } from "../../../lib/utils"; import { buildAuthenticatedOrgChartTenantPickerUrl, filterNonHanmacFamilyTenants, @@ -115,7 +117,6 @@ import { resolveTenantSelectionIds, type TenantViewMode, type TenantViewRow, - tenantMatchesListSearch, } from "./tenantListView"; const tenantCSVTemplate = @@ -264,7 +265,6 @@ function resolveImportParentSelection( function TenantListPage() { const navigate = useNavigate(); const [selectedIds, setSelectedIds] = React.useState([]); - const [search, setSearch] = React.useState(""); const [viewMode, setViewMode] = React.useState("tree"); const [scopeTenantId, setScopeTenantId] = React.useState(""); const [scopePickerOpen, setScopePickerOpen] = React.useState(false); @@ -304,6 +304,8 @@ function TenantListPage() { (d: TenantImportDetail) => d.action === importResultFilter, ); }, [importResult, importResultFilter]); + const [search, setSearch] = React.useState(""); + const debouncedSearch = React.useDeferredValue(search.trim()); const [selectedBulkStatus, setSelectedBulkStatus] = React.useState(""); const _tenantTableScrollRef = React.useRef(null); @@ -314,18 +316,18 @@ function TenantListPage() { const profileRole = normalizeAdminRole(profile?.role); const query = useInfiniteQuery({ - queryKey: ["tenants", "lazy"], + queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId], queryFn: ({ pageParam }) => fetchTenants( tenantPageSize, 0, - undefined, - pageParam ? pageParam : undefined, + scopeTenantId || undefined, + pageParam ? (pageParam as string) : undefined, + debouncedSearch, ), initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || lastPage.next_cursor || undefined, - enabled: profileRole === "super_admin", }); const deleteBulkMutation = useMutation({ @@ -436,6 +438,11 @@ 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 = @@ -443,15 +450,7 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const tenantPages = React.useMemo( - () => query.data?.pages ?? [], - [query.data?.pages], - ); - const rawTenants = React.useMemo( - () => tenantPages.flatMap((page) => page.items), - [tenantPages], - ); - const tenantTotal = tenantPages[0]?.total ?? 0; + const tenantTotal = query.data?.pages[0]?.total ?? 0; const hanmacFamilyTenantId = React.useMemo(() => { const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; if (typeof envTenantId === "string" && envTenantId.trim()) { @@ -924,6 +923,10 @@ function TenantListPage() { getSortIcon={getSortIcon} viewMode={viewMode} scopeTenantId={scopeTenantId} + fetchNextPage={query.fetchNextPage} + hasNextPage={!!query.hasNextPage} + isFetchingNextPage={query.isFetchingNextPage} + isLoading={query.isLoading} /> @@ -1500,6 +1503,10 @@ const TenantHierarchyView: React.FC<{ getSortIcon: (key: TenantSortKey) => React.ReactNode; viewMode: TenantViewMode; scopeTenantId: string; + fetchNextPage: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + isLoading: boolean; }> = ({ tenants, selectedIds, @@ -1514,7 +1521,13 @@ const TenantHierarchyView: React.FC<{ getSortIcon, viewMode, scopeTenantId, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, }) => { + const parentRef = React.useRef(null); + const { subTree } = React.useMemo( () => buildTenantFullTree(tenants, scopeTenantId || undefined), [scopeTenantId, tenants], @@ -1569,51 +1582,26 @@ const TenantHierarchyView: React.FC<{ const flattenedRows = React.useMemo(() => { if (viewMode === "table") { return sortItems( - getTenantViewRows(tenants, "table", scopeTenantId).filter((tenant) => - tenantMatchesListSearch(tenant, search), - ), + getTenantViewRows(tenants, "table", scopeTenantId), sortConfig, tenantSortResolvers, ); } const result: TenantViewRow[] = []; - const term = search.toLowerCase().trim(); - - // When searching, we show matched nodes and all their ancestors. - const matchedIds = new Set(); - if (term) { - const findMatches = (nodes: TenantNode[]) => { - for (const node of nodes) { - if (tenantMatchesListSearch(node, term)) { - matchedIds.add(node.id); - } - if (node.children) findMatches(node.children); - } - }; - findMatches(subTree); - } const collect = (nodes: TenantNode[], depth: number) => { // Sort nodes at the current depth const sortedNodes = sortItems(nodes, sortConfig, tenantSortResolvers); for (const node of sortedNodes) { - // If searching, show node if it matches OR any of its descendants match. - const hasMatchingDescendant = (n: TenantNode): boolean => { - if (matchedIds.has(n.id)) return true; - return n.children.some(hasMatchingDescendant); - }; - - if (!term || hasMatchingDescendant(node)) { - result.push({ ...node, depth }); - if ( - (term || expandedIds.has(node.id)) && - node.children && - node.children.length > 0 - ) { - collect(node.children, depth + 1); - } + result.push({ ...node, depth }); + if ( + expandedIds.has(node.id) && + node.children && + node.children.length > 0 + ) { + collect(node.children, depth + 1); } } }; @@ -1622,7 +1610,6 @@ const TenantHierarchyView: React.FC<{ }, [ expandedIds, scopeTenantId, - search, sortConfig, subTree, tenantSortResolvers, @@ -1630,6 +1617,34 @@ const TenantHierarchyView: React.FC<{ viewMode, ]); + const rowVirtualizer = useVirtualizer({ + count: flattenedRows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => _tenantEstimatedRowHeight, + overscan: 10, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + + React.useEffect(() => { + const lastItem = virtualRows[virtualRows.length - 1]; + if (!lastItem) return; + + if ( + lastItem.index >= flattenedRows.length - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage(); + } + }, [ + virtualRows, + flattenedRows.length, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + ]); + const visibleSelectableIds = React.useMemo( () => new Set(deletableTenants.map((tenant) => tenant.id)), [deletableTenants], @@ -1640,11 +1655,15 @@ const TenantHierarchyView: React.FC<{ return (
-
- +
+
- + 0 && @@ -1718,8 +1737,14 @@ const TenantHierarchyView: React.FC<{ - - {flattenedRows.length === 0 && ( + + {rowVirtualizer.getTotalSize() > 0 && virtualRows.length > 0 && ( + + + )} + + {flattenedRows.length === 0 && !isLoading && ( )} - {flattenedRows.map((node) => { + + {virtualRows.map((virtualRow) => { + const node = flattenedRows[virtualRow.index]; + const isSelected = selectedIds.includes(node.id); const hasChildren = viewMode === "tree" && node.children && @@ -1744,16 +1772,16 @@ const TenantHierarchyView: React.FC<{ return ( - + {isSeedTenant(node) ? ( ) : ( onSelect(node, !!checked)} /> )} @@ -1761,27 +1789,34 @@ const TenantHierarchyView: React.FC<{
-
- {hasChildren && !search ? ( - - ) : ( - node.depth > 0 && ( -
- ) - )} -
+ {viewMode === "tree" && ( +
+ {hasChildren && !search ? ( + + ) : ( + node.depth > 0 && ( +
+ ) + )} +
+ )} ); })} + + {rowVirtualizer.getTotalSize() > 0 && virtualRows.length > 0 && ( +
+ + )} + + {isFetchingNextPage && ( + + +
+ + {t("msg.common.loading_more", "Loading more...")} +
+
+
+ )}
+
+
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index f0720947..d5e281d4 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -293,11 +293,12 @@ export async function fetchTenants( offset = 0, parentId?: string, cursor?: string, + search?: string, ) { const { data } = await apiClient.get( "/v1/admin/tenants", { - params: { limit, offset, parentId, cursor }, + params: { limit, offset, parentId, cursor, search }, }, ); return data; diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index e7732323..e3bd3cc6 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -235,7 +235,7 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 { if h == nil || h.TenantRepo == nil { return 0 } - _, total, err := h.TenantRepo.List(ctx, 1, 0, "") + _, total, err := h.TenantRepo.List(ctx, 1, 0, "", "") if err != nil { return 0 } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d1e483b4..c18e6786 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -629,7 +629,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { } // 3. List and Filter Tenants - tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "") + tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants") } diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 2263cef6..3e086df6 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -3571,7 +3571,7 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error { } if role == domain.RoleSuperAdmin { - tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "") + tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to list tenants") } diff --git a/backend/internal/handler/hanmac_email_policy.go b/backend/internal/handler/hanmac_email_policy.go index 67a05e99..e5a92023 100644 --- a/backend/internal/handler/hanmac_email_policy.go +++ b/backend/internal/handler/hanmac_email_policy.go @@ -113,7 +113,7 @@ func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmail return nil, nil } - tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "") + tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "", "") if err != nil { return nil, err } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 74f93a2e..610fba6c 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -269,7 +269,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { if role != domain.RoleSuperAdmin { // Not a super admin: Only return the entire tree(s) of the tenants they belong to - allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") + allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "") if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -343,13 +343,13 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { } else { // Super Admin case if cursorRaw != "" && h.DB != nil { - tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw) + tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw, "") if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") } 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, "") if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -382,7 +382,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { }) } -func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string) ([]domain.Tenant, int64, string, 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) if err != nil { return nil, 0, "", err @@ -395,6 +395,12 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare pageQuery = pageQuery.Where("parent_id = ?", parentID) } + 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) + } + var total int64 if err := countQuery.Count(&total).Error; err != nil { return nil, 0, "", err @@ -422,7 +428,7 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { parentID := strings.TrimSpace(c.Query("parentId")) - allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") + allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } @@ -566,7 +572,7 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error { tenantIDBySlug := make(map[string]string) if h.Service != nil { - if tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, ""); err == nil { + if tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", ""); err == nil { for _, tenant := range tenants { tenantIDBySlug[strings.ToLower(tenant.Slug)] = tenant.ID } @@ -2336,7 +2342,7 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service is not configured") } - allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") + allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } @@ -2410,7 +2416,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te if err != nil { return nil, err } - usersByAppointment, _, err := h.UserRepo.List(ctx, 0, 10000, "", "") + usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", "", "") if err != nil { return nil, err } @@ -2741,7 +2747,7 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, err.Error()) } - allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") + allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 64157614..a7401cea 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -486,7 +486,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { // Expand manageableSlugs to the entire tenant tree (root + all descendants) if h.TenantService != nil && len(baseTenantIDs) > 0 { - allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "") + allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "", "") if err == nil { parentMap := make(map[string]string) for _, t := range allTenants { @@ -1614,7 +1614,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error { } // 1. Fetch Users using Repo for efficiency - users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug) + users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export") } diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index 1d808a06..5d6a1b2a 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -20,7 +20,7 @@ type TenantRepository interface { FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error - List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) + List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) DeleteBulk(ctx context.Context, ids []string) error } @@ -124,7 +124,7 @@ func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domai return r.db.WithContext(ctx).Create(&td).Error } -func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { +func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { var tenants []domain.Tenant var total int64 db := r.db.WithContext(ctx).Model(&domain.Tenant{}) @@ -133,6 +133,11 @@ func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID db = db.Where("parent_id = ?", parentID) } + if search != "" { + searchTerm := "%" + strings.ToLower(search) + "%" + db = db.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm) + } + if err := db.Count(&total).Error; err != nil { return nil, 0, err } diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 3a3856b9..dd4317eb 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -2,6 +2,7 @@ package repository import ( "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/pagination" "context" "fmt" "strings" @@ -17,7 +18,7 @@ type UserRepository interface { FindByID(ctx context.Context, id string) (*domain.User, error) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) - List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) + List(ctx context.Context, offset, limit int, search string, tenantSlug string, cursor string) ([]domain.User, int64, string, error) CountByTenant(ctx context.Context, tenantID string) (int64, error) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) @@ -215,7 +216,7 @@ func lowerStrings(arr []string) []string { return res } -func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { +func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string, cursorRaw string) ([]domain.User, int64, string, error) { var users []domain.User var total int64 db := r.db.WithContext(ctx).Model(&domain.User{}) @@ -232,14 +233,34 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str } if err := db.Count(&total).Error; err != nil { - return nil, 0, err + return nil, 0, "", err } - if err := db.Offset(offset).Limit(limit).Preload("Tenant").Find(&users).Error; err != nil { - return nil, 0, err + if cursorRaw != "" { + cursor, err := pagination.Decode(cursorRaw) + if err != nil { + return nil, 0, "", err + } + db = pagination.ApplyCreatedAtIDCursor(db, cursor, "created_at", "id") + } else { + db = db.Offset(offset) } - return users, total, nil + if err := db.Order("created_at desc, id desc").Limit(limit + 1).Preload("Tenant").Find(&users).Error; err != nil { + return nil, 0, "", err + } + + var items []domain.User + var nextCursor string + if len(users) > limit { + items = users[:limit] + last := items[limit-1] + nextCursor = pagination.Encode(last.CreatedAt, last.ID) + } else { + items = users + } + + return items, total, nextCursor, nil } func (r *userRepository) Delete(ctx context.Context, id string) error { diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 90b75d42..10415e69 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -18,7 +18,7 @@ type TenantService interface { GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) - ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) + ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) @@ -314,8 +314,8 @@ func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*doma return s.repo.FindBySlug(ctx, slug) } -func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { - return s.repo.List(ctx, limit, offset, parentID) +func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { + return s.repo.List(ctx, limit, offset, parentID, search) } func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 06f39f3f..ff890c78 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -938,7 +938,7 @@ func (s *worksmobileSyncService) hanmacRoot(ctx context.Context, tenantID string } func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID string) ([]domain.Tenant, error) { - all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "") + all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "", "") if err != nil { return nil, err } diff --git a/tenant_mh_manager.csv b/tenant_mh_manager.csv new file mode 100644 index 00000000..24950139 --- /dev/null +++ b/tenant_mh_manager.csv @@ -0,0 +1,2 @@ +tenant_id,name,type,slug +4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,mh-manager,COMPANY,mhd-tdc diff --git a/test_users_bulk_comprehensive.csv b/test_users_bulk_comprehensive.csv new file mode 100644 index 00000000..4da5a693 --- /dev/null +++ b/test_users_bulk_comprehensive.csv @@ -0,0 +1,5 @@ +uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id +550e8400-e29b-41d4-a716-446655441001,user-unchanged@example.com,동일 테스트,010-1111-1001,user,test-tenant,Platform,Senior,,Engineer,E1001 +550e8400-e29b-41d4-a716-446655441002,user-modified@example.com,이름수정 완료,010-1111-1002,user,test-tenant,Design,Junior,,Designer,E1002 +550e8400-e29b-41d4-a716-446655441003,user-restored@example.com,복구 테스트,010-1111-1003,user,test-tenant,Sales,Manager,,Manager,E1003 +550e8400-e29b-41d4-a716-446655441005,user-brand-new@example.com,완전신규 테스트,010-1111-1005,user,test-tenant,R&D,Senior,,Lead,E1005 diff --git a/test_users_bulk_final_check.csv b/test_users_bulk_final_check.csv new file mode 100644 index 00000000..fcdba19e --- /dev/null +++ b/test_users_bulk_final_check.csv @@ -0,0 +1,5 @@ +uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id +550e8400-e29b-41d4-a716-446655440010,clean-user@example.com,Clean User,010-3333-0001,user,test-tenant,R&D,Senior,,Engineer,E010 +550e8400-e29b-41d4-a716-446655440010,conflict-user@example.com,Conflict User,010-3333-0002,user,test-tenant,Sales,,,E011 +550e8400-e29b-41d4-a716-446655440012,same-person@example.com,Same Person,010-3333-0003,user,test-tenant,HR,,,E012 +550e8400-e29b-41d4-a716-446655440013,reclaim-id@example.com,Reclaim ID User,010-3333-0004,user,test-tenant,Finance,,,E010 diff --git a/test_users_bulk_reimport_fix.csv b/test_users_bulk_reimport_fix.csv new file mode 100644 index 00000000..3261ec73 --- /dev/null +++ b/test_users_bulk_reimport_fix.csv @@ -0,0 +1,5 @@ +uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id +550e8400-e29b-41d4-a716-446655440001,uuid-user-1@example.com,UUID User 1 (Re-import),010-1111-0001,user,test-tenant,Platform,Senior,Lead,Engineer,E001 +550e8400-e29b-41d4-a716-446655440002,uuid-user-2@example.com,UUID User 2 (Re-import),010-1111-0002,user,test-tenant,Design,Junior,,Designer,E002 +550e8400-e29b-41d4-a716-446655440008,new-reimport-user@example.com,New Re-import User,010-1111-0008,user,test-tenant,HR,Manager,,HR,E008 +550e8400-e29b-41d4-a716-446655440009,conflict-uuid@example.com,Conflict UUID User,010-1111-0009,user,test-tenant,Sales,,,E001 diff --git a/test_users_bulk_uuid.csv b/test_users_bulk_uuid.csv new file mode 100644 index 00000000..29541f82 --- /dev/null +++ b/test_users_bulk_uuid.csv @@ -0,0 +1,5 @@ +uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id +550e8400-e29b-41d4-a716-446655440001,uuid-user-1@example.com,UUID User 1,010-1111-0001,user,test-tenant,Platform,Senior,Lead,Engineer,E001 +550e8400-e29b-41d4-a716-446655440002,uuid-user-2@example.com,UUID User 2,010-1111-0002,user,test-tenant,Design,Junior,,Designer,E002 +,no-uuid-user@example.com,No UUID User,010-1111-0003,user,test-tenant,Sales,Manager,Head,,E003 +550e8400-e29b-41d4-a716-446655440004,id-column-uuid@example.com,ID Col UUID User,010-2222-0001,user,test-tenant diff --git a/test_users_bulk_with_n_secondary_emails.csv b/test_users_bulk_with_n_secondary_emails.csv new file mode 100644 index 00000000..520d8fed --- /dev/null +++ b/test_users_bulk_with_n_secondary_emails.csv @@ -0,0 +1,4 @@ +email,secondary_emails,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1 +alice@hanmac.com,alice.personal@gmail.com;alice.sub@naver.com;alice.old@hanmac.co.kr,Alice Kim,010-1111-2222,user,hanmac-family,개발팀,선임,팀원,프론트엔드,EMP100,,,,,, +bob@hanmac.com,,Bob Lee,010-3333-4444,user,hanmac-family,디자인팀,책임,팀장,UI/UX,EMP101,,,,,, +charlie@hanmac.com,charlie.backup1@test.com;charlie.backup2@test.com;charlie.backup3@test.com;charlie.backup4@test.com;charlie.backup5@test.com,Charlie Park,010-5555-6666,user,hanmac-family,인사팀,수석,,HR,EMP102,,,,,,