mmday-firebase/src/services/adminUserService.ts
윤정민 da8ecf6d4f Support revoking admin role via setAdminRole
- appointAdmin을 setAdminRole로 교체 — isAdmin boolean 인자로 부여와 해제를 모두 처리
- 자기 자신의 관리자 권한 해제는 409(CANNOT_REMOVE_SELF_ADMIN)로 차단
- 비활성 유저 검사는 권한 부여 시에만 적용, 응답 형태를 {isAdmin, changed}로 변경
- 어드민 핸들러 라우트와 테스트를 새 시그니처로 갱신
2026-07-23 14:13:29 +09:00

126 lines
4.2 KiB
TypeScript

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<AdminUserDto[]> {
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<AdminRoleUpdateResult> {
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<string, unknown> = { ...(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<AdminUserDto[]> {
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<string, AdminUserDto>();
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<AdminUserPageDto> {
const page = await listUsersByCreatedAt(Number(limitRaw) || 20, cursor);
return {
items: await attachAdminStatus(page.items.map(({ uid, user }) => toAdminUserDto(uid, user))),
cursor: page.cursor,
};
}