import { auth } from "../firebase"; import { HttpError } from "../middleware/errors"; import { getUser, listUsersByCreatedAt, searchUsersByDisplayNamePrefix, } from "../repositories/userRepository"; import type { User } from "../types/panit"; import { toAdminUserDto, type AdminUserDto, type AdminUserPageDto, } from "../types/dto/adminUserDto"; const MAX_SEARCH_RESULTS = 30; async function attachAdminStatus(users: AdminUserDto[]): Promise { if (users.length === 0) return users; const records = await auth.getUsers(users.map(({ uid }) => ({ uid }))); const adminUids = new Set( records.users .filter((record) => record.customClaims?.admin === true) .map((record) => record.uid) ); return users.map((user) => ({ ...user, isAdmin: adminUids.has(user.uid) })); } export interface AdminRoleUpdateResult { uid: string; isAdmin: boolean; changed: boolean; } /** 기존 custom claims를 보존하면서 관리자 권한을 부여하거나 해제한다. */ export async function setAdminRole( uidRaw: unknown, isAdminRaw: unknown, actorUid: string ): Promise { if ( typeof uidRaw !== "string" || uidRaw.length < 1 || uidRaw.length > 128 || uidRaw.trim() !== uidRaw || uidRaw.includes("/") ) { throw new HttpError(400, "invalid uid", "INVALID_INPUT"); } if (typeof isAdminRaw !== "boolean") { throw new HttpError(400, "isAdmin must be boolean", "INVALID_INPUT"); } if (!isAdminRaw && uidRaw === actorUid) { throw new HttpError(409, "cannot remove own admin role", "CANNOT_REMOVE_SELF_ADMIN"); } const user = await getUser(uidRaw); if (!user) throw new HttpError(404, "user not found", "USER_NOT_FOUND"); if (isAdminRaw && user.active === false) { throw new HttpError(409, "inactive user cannot be admin", "INACTIVE_USER"); } const record = await auth.getUser(uidRaw).catch(() => { throw new HttpError(404, "auth user not found", "USER_NOT_FOUND"); }); const currentIsAdmin = record.customClaims?.admin === true; if (currentIsAdmin === isAdminRaw) { return { uid: uidRaw, isAdmin: isAdminRaw, changed: false }; } const nextClaims: Record = { ...(record.customClaims ?? {}) }; if (isAdminRaw) nextClaims.admin = true; else delete nextClaims.admin; await auth.setCustomUserClaims(uidRaw, nextClaims); return { uid: uidRaw, isAdmin: isAdminRaw, changed: true }; } /** * 어드민 유저 통합 검색. 한 입력으로 세 경로를 함께 조회한다: * - uid 정확 일치 (공백 없는 10자 이상 입력일 때만 문서 1건 조회) * - 이메일 정확 일치 ('@' 포함 시 Auth 조회 — user 문서가 없으면 결과 제외) * - displayName 접두어 매치 (Firestore 접두어 쿼리라 중간 문자열 매치는 안 됨) * 결과는 uid 기준 중복 제거하며 정확 일치를 앞에 둔다. */ export async function searchAdminUsers( qRaw: unknown, limitRaw: unknown ): Promise { if (typeof qRaw !== "string" || qRaw.trim().length === 0) { throw new HttpError(400, "q is required", "INVALID_INPUT"); } const q = qRaw.trim(); const limit = Math.min(Math.max(Number(limitRaw) || 10, 1), MAX_SEARCH_RESULTS); const results = new Map(); const add = (uid: string, user: User | null) => { if (user && !results.has(uid)) results.set(uid, toAdminUserDto(uid, user)); }; if (!q.includes(" ") && q.length >= 10) { add(q, await getUser(q)); } if (q.includes("@")) { try { const record = await auth.getUserByEmail(q); add(record.uid, await getUser(record.uid)); } catch { // 해당 이메일의 Auth 계정 없음 — 검색 결과에서 제외하면 된다. } } for (const { uid, user } of await searchUsersByDisplayNamePrefix(q, limit)) { add(uid, user); } return attachAdminStatus([...results.values()].slice(0, limit)); } /** 가입일 내림차순 유저 목록 페이지. */ export async function listAdminUsers( limitRaw: unknown, cursor?: string ): Promise { const page = await listUsersByCreatedAt(Number(limitRaw) || 20, cursor); return { items: await attachAdminStatus(page.items.map(({ uid, user }) => toAdminUserDto(uid, user))), cursor: page.cursor, }; }