From 5b6313f16f508882a0ea67716b7dbaa1c6967f04 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 30 Jun 2025 08:28:13 +0000 Subject: (대표님) 20250630 16시 - 유저 도메인별 라우터 분리와 보안성검토 대응 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/users/access-control/assign-domain-dialog.tsx | 253 +++++++++++++++++++++ lib/users/access-control/domain-stats-cards.tsx | 232 +++++++++++++++++++ lib/users/access-control/users-table-columns.tsx | 149 ++++++++++++ .../access-control/users-table-toolbar-actions.tsx | 51 +++++ lib/users/access-control/users-table.tsx | 166 ++++++++++++++ lib/users/auth/verifyCredentails.ts | 5 +- lib/users/service.ts | 250 +++++++++++++++++++- 7 files changed, 1101 insertions(+), 5 deletions(-) create mode 100644 lib/users/access-control/assign-domain-dialog.tsx create mode 100644 lib/users/access-control/domain-stats-cards.tsx create mode 100644 lib/users/access-control/users-table-columns.tsx create mode 100644 lib/users/access-control/users-table-toolbar-actions.tsx create mode 100644 lib/users/access-control/users-table.tsx (limited to 'lib/users') diff --git a/lib/users/access-control/assign-domain-dialog.tsx b/lib/users/access-control/assign-domain-dialog.tsx new file mode 100644 index 00000000..fda06b28 --- /dev/null +++ b/lib/users/access-control/assign-domain-dialog.tsx @@ -0,0 +1,253 @@ +// components/assign-domain-dialog.tsx +"use client" + +import * as React from "react" +import { User } from "@/db/schema/users" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Users, Loader2, CheckCircle } from "lucide-react" +import { toast } from "sonner" +import { assignUsersDomain } from "../service" + +interface AssignDomainDialogProps { + users: User[] +} + +// 도메인 옵션 정의 +const domainOptions = [ + { + value: "pending", + label: "승인 대기", + description: "신규 사용자 (기본 메뉴만)", + color: "yellow", + icon: "🟡" + }, + { + value: "evcp", + label: "전체 시스템", + description: "모든 메뉴 접근 (관리자급)", + color: "blue", + icon: "🔵" + }, + { + value: "procurement", + label: "구매관리팀", + description: "구매, 협력업체, 계약 관리", + color: "green", + icon: "🟢" + }, + { + value: "sales", + label: "기술영업팀", + description: "기술영업, 견적, 프로젝트 관리", + color: "purple", + icon: "🟣" + }, + { + value: "engineering", + label: "설계관리팀", + description: "설계, 기술평가, 문서 관리", + color: "orange", + icon: "🟠" + }, + { + value: "partners", + label: "협력업체", + description: "외부 협력업체용 기능", + color: "indigo", + icon: "🟦" + } +] + +export function AssignDomainDialog({ users }: AssignDomainDialogProps) { + const [open, setOpen] = React.useState(false) + const [selectedDomain, setSelectedDomain] = React.useState("") + const [isLoading, setIsLoading] = React.useState(false) + + // 도메인별 사용자 그룹핑 + const usersByDomain = React.useMemo(() => { + const groups: Record = {} + users.forEach(user => { + const domain = user.domain || "pending" + if (!groups[domain]) { + groups[domain] = [] + } + groups[domain].push(user) + }) + return groups + }, [users]) + + const handleAssign = async () => { + if (!selectedDomain) { + toast.error("도메인을 선택해주세요.") + return + } + + setIsLoading(true) + try { + const userIds = users.map(user => user.id) + const result = await assignUsersDomain(userIds, selectedDomain as any) + + if (result.success) { + toast.success(`${users.length}명의 사용자에게 ${selectedDomain} 도메인이 할당되었습니다.`) + setOpen(false) + setSelectedDomain("") + // 테이블 새로고침을 위해 router.refresh() 또는 revalidation 필요 + window.location.reload() // 간단한 방법 + } else { + toast.error(result.message || "도메인 할당 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("도메인 할당 오류:", error) + toast.error("도메인 할당 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const selectedDomainInfo = domainOptions.find(option => option.value === selectedDomain) + + return ( + + + + + + + + + + 사용자 도메인 할당 + + + 선택된 {users.length}명의 사용자에게 도메인을 할당합니다. + + + +
+ {/* 현재 사용자 도메인 분포 */} +
+

현재 도메인 분포

+
+ {Object.entries(usersByDomain).map(([domain, domainUsers]) => { + const domainInfo = domainOptions.find(opt => opt.value === domain) + return ( + + {domainInfo?.icon || "⚪"} + {domainInfo?.label || domain} ({domainUsers.length}명) + + ) + })} +
+
+ + + + {/* 사용자 목록 */} +
+

대상 사용자

+ +
+ {users.map((user, index) => ( +
+
+ {user.name} + ({user.email}) +
+ + {domainOptions.find(opt => opt.value === user.domain)?.label || user.domain} + +
+ ))} +
+
+
+ + + + {/* 도메인 선택 */} +
+

할당할 도메인

+ +
+ + {/* 선택된 도메인 미리보기 */} + {selectedDomainInfo && ( +
+
+ + 선택된 도메인 +
+
+ {selectedDomainInfo.icon} +
+
{selectedDomainInfo.label}
+
+ {selectedDomainInfo.description} +
+
+
+
+ )} +
+ + + + + +
+
+ ) +} \ No newline at end of file diff --git a/lib/users/access-control/domain-stats-cards.tsx b/lib/users/access-control/domain-stats-cards.tsx new file mode 100644 index 00000000..e6320e12 --- /dev/null +++ b/lib/users/access-control/domain-stats-cards.tsx @@ -0,0 +1,232 @@ +// components/domain-stats-cards.tsx +"use client" + +import * as React from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Users, + Clock, + Shield, + ShoppingCart, + TrendingUp, + Settings, + Building, + AlertCircle +} from "lucide-react" +import { toast } from "sonner" +import { getUserDomainStats } from "../service" + +interface DomainStatsCardsProps { + onDomainFilter: (domain: string | null) => void + currentFilter?: string | null +} + +// 도메인별 설정 +const domainConfig = { + pending: { + label: "승인 대기", + description: "신규 사용자", + icon: Clock, + color: "bg-yellow-500", + textColor: "text-yellow-700", + bgColor: "bg-yellow-50", + borderColor: "border-yellow-200" + }, + evcp: { + label: "전체 시스템", + description: "관리자급", + icon: Shield, + color: "bg-blue-500", + textColor: "text-blue-700", + bgColor: "bg-blue-50", + borderColor: "border-blue-200" + }, + procurement: { + label: "구매관리팀", + description: "구매/계약 관리", + icon: ShoppingCart, + color: "bg-green-500", + textColor: "text-green-700", + bgColor: "bg-green-50", + borderColor: "border-green-200" + }, + sales: { + label: "기술영업팀", + description: "영업/프로젝트", + icon: TrendingUp, + color: "bg-purple-500", + textColor: "text-purple-700", + bgColor: "bg-purple-50", + borderColor: "border-purple-200" + }, + engineering: { + label: "설계관리팀", + description: "설계/기술평가", + icon: Settings, + color: "bg-orange-500", + textColor: "text-orange-700", + bgColor: "bg-orange-50", + borderColor: "border-orange-200" + }, + // partners: { + // label: "협력업체", + // description: "외부 업체", + // icon: Building, + // color: "bg-indigo-500", + // textColor: "text-indigo-700", + // bgColor: "bg-indigo-50", + // borderColor: "border-indigo-200" + // } +} + +interface DomainStats { + domain: string + count: number +} + +export function DomainStatsCards({ onDomainFilter, currentFilter }: DomainStatsCardsProps) { + const [stats, setStats] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(true) + const [totalUsers, setTotalUsers] = React.useState(0) + + // 통계 데이터 로드 + React.useEffect(() => { + const loadStats = async () => { + setIsLoading(true) + try { + const result = await getUserDomainStats() + if (result.success) { + setStats(result.data) + setTotalUsers(result.data.reduce((sum, item) => sum + item.count, 0)) + } else { + toast.error(result.message) + } + } catch (error) { + console.error("통계 로드 오류:", error) + toast.error("통계를 불러오는 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + loadStats() + }, []) + + // 도메인별 카드 클릭 핸들러 + const handleDomainClick = (domain: string) => { + if (currentFilter === domain) { + // 이미 선택된 도메인이면 필터 해제 + onDomainFilter(null) + } else { + // 새로운 도메인 필터 적용 + onDomainFilter(domain) + } + } + + // pending 사용자 수 가져오기 + const pendingCount = stats.find(s => s.domain === "pending")?.count || 0 + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + +
+
+
+ ))} +
+ ) + } + + return ( +
+ {/* 요약 정보 */} +
+
+

도메인별 사용자 현황

+

+ 총 {totalUsers}명의 사용자가 등록되어 있습니다. +

+
+ + {pendingCount > 0 && ( + + )} +
+ + {/* 도메인별 통계 카드 */} +
+ {Object.entries(domainConfig).map(([domain, config]) => { + const domainStat = stats.find(s => s.domain === domain) + const count = domainStat?.count || 0 + const isActive = currentFilter === domain + const IconComponent = config.icon + + return ( + handleDomainClick(domain)} + > + +
+
+ +
+
+
+

+ {config.label} +

+ + {count} + +
+

+ {config.description} +

+
+
+
+
+ ) + })} +
+ + {/* 필터 상태 표시 */} + {currentFilter && ( +
+ 필터 적용됨: + + {domainConfig[currentFilter as keyof typeof domainConfig]?.label || currentFilter} + + +
+ )} +
+ ) +} \ No newline at end of file diff --git a/lib/users/access-control/users-table-columns.tsx b/lib/users/access-control/users-table-columns.tsx new file mode 100644 index 00000000..7e510b96 --- /dev/null +++ b/lib/users/access-control/users-table-columns.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { type User } from "@/db/schema/users" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { getErrorMessage } from "@/lib/handle-error" + +import { toast } from "sonner" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { MultiSelect } from "@/components/ui/multi-select" +import { userAccessColumnsConfig } from "@/config/userAccessColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns(): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + + + const groupMap: Record[]> = {} + + userAccessColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "domain") { + const domainValues = row.original.domain; + return ( +
+ + + {domainValues} + + +
+ ); + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + ] +} \ No newline at end of file diff --git a/lib/users/access-control/users-table-toolbar-actions.tsx b/lib/users/access-control/users-table-toolbar-actions.tsx new file mode 100644 index 00000000..6a431016 --- /dev/null +++ b/lib/users/access-control/users-table-toolbar-actions.tsx @@ -0,0 +1,51 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { User } from "@/db/schema/users" +import { AssignDomainDialog } from "./assign-domain-dialog" + +interface UsersTableToolbarActionsProps { + table: Table +} + +export function UsersTableToolbarActions({ table }: UsersTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef(null) + + return ( +
+ + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + row.original)} + /> + ) : null} + + + + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/users/access-control/users-table.tsx b/lib/users/access-control/users-table.tsx new file mode 100644 index 00000000..50ce4dee --- /dev/null +++ b/lib/users/access-control/users-table.tsx @@ -0,0 +1,166 @@ +"use client" + +import * as React from "react" +import { User} from "@/db/schema/users" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + +import { getUsersNotPartners } from "@/lib/users/service"; +import { getColumns } from "./users-table-columns" +import { UsersTableToolbarActions } from "./users-table-toolbar-actions" +import { DomainStatsCards } from "./domain-stats-cards" + +interface UsersTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + +export function UserAccessControlTable({ promises }: UsersTableProps) { + const [{ data, pageCount }] = React.use(promises) + const [currentDomainFilter, setCurrentDomainFilter] = React.useState(null) + + const columns = React.useMemo(() => getColumns(), []) + + // 도메인 필터에 따른 데이터 필터링 + const filteredData = React.useMemo(() => { + if (!currentDomainFilter) { + return data // 필터가 없으면 전체 데이터 + } + return data.filter(user => user.domain === currentDomainFilter) + }, [data, currentDomainFilter]) + + // 필터링된 데이터의 페이지 수 재계산 + const filteredPageCount = React.useMemo(() => { + if (!currentDomainFilter) { + return pageCount // 필터가 없으면 원본 페이지 수 + } + // 필터링된 데이터는 페이지 수를 1로 설정 (클라이언트 필터링이므로) + return 1 + }, [pageCount, currentDomainFilter]) + + /** + * Advanced filter fields for the data table. + */ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "name", + label: "사용자명", + type: "text", + }, + { + id: "email", + label: "이메일", + type: "text", + }, + { + id: "deptName", + label: "부서", + type: "text", + }, + { + id: "domain", + label: "도메인", + type: "multi-select", + options: [ + { label: "🟡 승인 대기", value: "pending" }, + { label: "🔵 전체 시스템", value: "evcp" }, + { label: "🟢 구매관리팀", value: "procurement" }, + { label: "🟣 기술영업팀", value: "sales" }, + { label: "🟠 설계관리팀", value: "engineering" }, + { label: "🟦 협력업체", value: "partners" }, + ], + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data: filteredData, // 필터링된 데이터 사용 + columns, + pageCount: filteredPageCount, // 필터링된 페이지 수 사용 + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.id}`, + shallow: false, + clearOnDefault: true, + }) + + // 도메인 필터 핸들러 (단순화) + const handleDomainFilter = React.useCallback((domain: string | null) => { + setCurrentDomainFilter(domain) + }, []) + + return ( +
+ {/* 도메인 통계 카드 */} + + + {/* 현재 필터 상태 표시 */} + {/* {currentDomainFilter && ( +
+
+ + 필터 적용됨: + + + {(() => { + const domainLabels = { + pending: "🟡 승인 대기", + evcp: "🔵 전체 시스템", + procurement: "🟢 구매관리팀", + sales: "🟣 기술영업팀", + engineering: "🟠 설계관리팀", + partners: "🟦 협력업체" + } + return domainLabels[currentDomainFilter as keyof typeof domainLabels] || currentDomainFilter + })()} + +
+
+ + {filteredData.length}명 표시 + + +
+
+ )} */} + + {/* 데이터 테이블 */} + + + + + +
+ ) +} \ No newline at end of file diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts index ec3159a8..1b67b874 100644 --- a/lib/users/auth/verifyCredentails.ts +++ b/lib/users/auth/verifyCredentails.ts @@ -458,8 +458,11 @@ export async function verifySGipsCredentials( error?: string; }> { try { + + const sgipsUrl = process.env.S_GIPS_URL || "http://qa.shi-api.com/evcp/Common/verifySgipsUser" + // 1. S-Gips API 호출로 인증 확인 - const response = await fetch(process.env.S_GIPS_URL, { + const response = await fetch(sgipsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/lib/users/service.ts b/lib/users/service.ts index ad01c22a..9671abfb 100644 --- a/lib/users/service.ts +++ b/lib/users/service.ts @@ -6,15 +6,15 @@ import { getAllUsers, createUser, getUserById, updateUser, deleteUser, getUserBy import logger from '@/lib/logger'; import { Role, userRoles, users, userView, type User } from '@/db/schema/users'; import { saveDocument } from '../storage'; -import { GetUsersSchema } from '../admin-users/validations'; -import { revalidateTag, unstable_cache, unstable_noStore } from 'next/cache'; +import { GetSimpleUsersSchema, GetUsersSchema } from '../admin-users/validations'; +import { revalidatePath, revalidateTag, unstable_cache, unstable_noStore } from 'next/cache'; import { filterColumns } from '../filter-columns'; -import { countUsers, selectUsersWithCompanyAndRoles } from '../admin-users/repository'; +import { countUsers, countUsersSimple, selectUsers, selectUsersWithCompanyAndRoles } from '../admin-users/repository'; import db from "@/db/db"; import { getErrorMessage } from "@/lib/handle-error"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray } from "drizzle-orm"; +import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray, ne } from "drizzle-orm"; interface AssignUsersArgs { roleId: number @@ -340,6 +340,82 @@ export async function getUsersEVCP(input: GetUsersSchema) { )(); } + +export async function getUsersNotPartners(input: GetSimpleUsersSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: users, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(users.name, s), + ilike(users.email, s), + ilike(users.deptName, s), + ); + } + + // (3) 디폴트 domainWhere = eq(userView.domain, "partners") + // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X + let domainWhere; + const hasDomainFilter = input.filters?.some((f) => f.id === "domain"); + if (!hasDomainFilter) { + domainWhere = ne(users.domain, "partners"); + } + + // (4) 최종 where + const finalWhere = and(advancedWhere, globalWhere, domainWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(users[item.id]) : asc(users[item.id]) + ) + : [desc(users.createdAt)]; + + // ... + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsers(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + + console.log(data) + + const total = await countUsersSimple(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + + console.log(err) + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["users-access-control"], + } + )(); +} + export async function getAllRoles(): Promise { try { return await findAllRoles(); @@ -507,3 +583,169 @@ export async function assignUsersToRole(roleId: number, userIds: number[]) { } } + + +export type UserDomain = "pending" | "evcp" | "procurement" | "sales" | "engineering" | "partners" + +/** + * 여러 사용자에게 도메인을 일괄 할당하는 함수 + */ +export async function assignUsersDomain( + userIds: number[], + domain: UserDomain +) { + try { + if (!userIds.length) { + return { + success: false, + message: "할당할 사용자가 없습니다." + } + } + + if (!domain) { + return { + success: false, + message: "도메인을 선택해주세요." + } + } + + // 사용자들의 도메인 업데이트 + const result = await db + .update(users) + .set({ + domain, + updatedAt: new Date(), + }) + .where(inArray(users.id, userIds)) + .returning({ + id: users.id, + name: users.name, + email: users.email, + domain: users.domain, + }) + + // 관련 페이지들 revalidate + revalidatePath("/evcp/user-management") + revalidatePath("/") + + return { + success: true, + message: `${result.length}명의 사용자 도메인이 업데이트되었습니다.`, + data: result + } + } catch (error) { + console.error("사용자 도메인 할당 오류:", error) + return { + success: false, + message: "도메인 할당 중 오류가 발생했습니다." + } + } +} + +/** + * 단일 사용자의 도메인을 변경하는 함수 + */ +export async function assignUserDomain( + userId: number, + domain: UserDomain +) { + try { + const result = await db + .update(users) + .set({ + domain, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)) + .returning({ + id: users.id, + name: users.name, + email: users.email, + domain: users.domain, + }) + + if (result.length === 0) { + return { + success: false, + message: "사용자를 찾을 수 없습니다." + } + } + + revalidatePath("/evcp/user-management") + revalidatePath("/evcp/users") + + return { + success: true, + message: `${result[0].name}님의 도메인이 ${domain}으로 변경되었습니다.`, + data: result[0] + } + } catch (error) { + console.error("사용자 도메인 할당 오류:", error) + return { + success: false, + message: "도메인 할당 중 오류가 발생했습니다." + } + } +} + +/** + * 도메인별 사용자 통계를 조회하는 함수 + */ +export async function getUserDomainStats() { + try { + const stats = await db + .select({ + domain: users.domain, + count: count(), + }) + .from(users) + .where(eq(users.isActive, true)) + .groupBy(users.domain) + + return { + success: true, + data: stats + } + } catch (error) { + console.error("도메인 통계 조회 오류:", error) + return { + success: false, + message: "통계 조회 중 오류가 발생했습니다.", + data: [] + } + } +} + +/** + * pending 도메인 사용자 목록을 조회하는 함수 (관리자용) + */ +export async function getPendingUsers() { + try { + const pendingUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + createdAt: users.createdAt, + domain: users.domain, + }) + .from(users) + .where(and( + eq(users.domain, "pending"), + eq(users.isActive, true) + )) + .orderBy(desc(users.createdAt)) + + return { + success: true, + data: pendingUsers + } + } catch (error) { + console.error("pending 사용자 조회 오류:", error) + return { + success: false, + message: "사용자 조회 중 오류가 발생했습니다.", + data: [] + } + } +} \ No newline at end of file -- cgit v1.2.3