diff --git a/src/handlers/adminHandlers.ts b/src/handlers/adminHandlers.ts index ef57814..59485be 100644 --- a/src/handlers/adminHandlers.ts +++ b/src/handlers/adminHandlers.ts @@ -12,6 +12,7 @@ import { Timestamp } from "firebase-admin/firestore"; import type { OrderStatus, ProductDoc } from "../types/reward"; import { transitionOrder } from "../services/orderService"; import { invalidateRewardCatalog } from "../services/rewardCatalogService"; +import { listAdminUsers, searchAdminUsers } from "../services/adminUserService"; import { toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto, type AdminProductDto, type LedgerPageDto, type OrderPageDto, @@ -98,6 +99,17 @@ export const admin = onRequest(async (req, res) => { } if (tail === "order/status" && req.method === "POST") { const { orderId, status } = req.body ?? {}; res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, { admin: true })); return; } + if (tail === "users/search" && req.method === "GET") { + res.json(await searchAdminUsers(req.query.q, req.query.limit)); + return; + } + + if (tail === "users/list" && req.method === "GET") { + const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined; + res.json(await listAdminUsers(req.query.limit, cursor)); + return; + } + if (tail === "products/list" && req.method === "GET") { const products = await listAllProducts(); const dto: AdminProductDto[] = products.map((p) => toAdminProductDto(p.id, p)); diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 37b8c3d..b83e2da 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -188,6 +188,50 @@ export async function findUidByDisplayName( return snap.empty ? null : snap.docs[0].id; } +/** 어드민 검색/목록용 — 문서 id(uid)를 함께 얹은 형태. */ +export interface UserWithUid { + uid: string; + user: User; +} + +/** + * displayName 접두어로 유저를 검색한다 (어드민 콘솔용). + * Firestore 접두어 쿼리(orderBy + startAt/endAt )라 중간 문자열 매치는 안 된다. + */ +export async function searchUsersByDisplayNamePrefix( + prefix: string, + limit = 10 +): Promise { + const snap = await firestore + .collection(COLLECTION) + .orderBy("displayName") + .startAt(prefix) + .endAt(prefix + "") + .limit(Math.min(Math.max(limit, 1), 30)) + .get(); + return snap.docs.map((d) => ({ uid: d.id, user: d.data() as User })); +} + +/** 가입일 내림차순 유저 목록 (어드민 콘솔용). cursor 는 직전 페이지 마지막 문서의 uid. */ +export async function listUsersByCreatedAt( + limit = 20, + cursor?: string +): Promise<{ items: UserWithUid[]; cursor: string | null }> { + let q = firestore + .collection(COLLECTION) + .orderBy("createdAt", "desc") + .limit(Math.min(Math.max(limit, 1), 100)); + if (cursor) { + const c = await firestore.collection(COLLECTION).doc(cursor).get(); + if (c.exists) q = q.startAfter(c) as typeof q; + } + const snap = await q.get(); + return { + items: snap.docs.map((d) => ({ uid: d.id, user: d.data() as User })), + cursor: snap.docs.length ? snap.docs[snap.docs.length - 1].id : null, + }; +} + /** * 신규 유저 문서를 생성한다. `createdAt`은 서버 타임스탬프로 기록되며, * 같은 UID가 있으면 전체 덮어쓴다(`merge: false`). diff --git a/src/services/adminUserService.ts b/src/services/adminUserService.ts new file mode 100644 index 0000000..7f48730 --- /dev/null +++ b/src/services/adminUserService.ts @@ -0,0 +1,67 @@ +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; + +/** + * 어드민 유저 통합 검색. 한 입력으로 세 경로를 함께 조회한다: + * - 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 [...results.values()].slice(0, limit); +} + +/** 가입일 내림차순 유저 목록 페이지. */ +export async function listAdminUsers( + limitRaw: unknown, + cursor?: string +): Promise { + const page = await listUsersByCreatedAt(Number(limitRaw) || 20, cursor); + return { + items: page.items.map(({ uid, user }) => toAdminUserDto(uid, user)), + cursor: page.cursor, + }; +} diff --git a/src/types/dto/adminUserDto.ts b/src/types/dto/adminUserDto.ts new file mode 100644 index 0000000..247b5bf --- /dev/null +++ b/src/types/dto/adminUserDto.ts @@ -0,0 +1,40 @@ +import type { Provider, TeamCode, User } from "../panit"; +import { toIsoOrUndefined } from "./iso"; + +/** + * 어드민 콘솔 유저 요약. + * streak/티어/티켓 등 lazy 보정이 필요한 필드는 의도적으로 제외한다 + * (`userService.toUserProfile`와 같은 이유 — 해당 값은 /stats 경유가 원칙). + */ +export interface AdminUserDto { + uid: string; + displayName: string; + email: string; + photoUrl?: string; + provider: Provider; + favoriteTeamCode?: TeamCode; + /** active 필드가 없는 구(백필 전) 문서는 활성으로 간주한다. */ + active: boolean; + createdAt?: string; + deactivatedAt?: string; +} + +export interface AdminUserPageDto { + items: AdminUserDto[]; + cursor: string | null; +} + +/** User 문서를 어드민 요약 DTO로 변환한다. 날짜는 UTC ISO 문자열, 옵션 필드는 키 자체를 생략. */ +export function toAdminUserDto(uid: string, user: User): AdminUserDto { + return { + uid, + displayName: user.displayName, + email: user.email, + provider: user.provider, + active: user.active !== false, + ...(user.photoUrl !== undefined ? { photoUrl: user.photoUrl } : {}), + ...(user.favoriteTeamCode !== undefined ? { favoriteTeamCode: user.favoriteTeamCode } : {}), + ...(user.createdAt ? { createdAt: toIsoOrUndefined(user.createdAt) } : {}), + ...(user.deactivatedAt ? { deactivatedAt: toIsoOrUndefined(user.deactivatedAt) } : {}), + }; +} diff --git a/tests/services/adminUserService.test.ts b/tests/services/adminUserService.test.ts new file mode 100644 index 0000000..ec5e033 --- /dev/null +++ b/tests/services/adminUserService.test.ts @@ -0,0 +1,86 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { firestore } from "../../src/firebase"; +import { listAdminUsers, searchAdminUsers } from "../../src/services/adminUserService"; + +// 다른 테스트가 만드는 users 문서와 섞이지 않도록 고유 접두어를 쓴다. +const PREFIX = "검색전용"; + +const seedUsers = [ + { + uid: "admin-user-search-test-1", + displayName: `${PREFIX}호랑이`, + email: "search-test-1@example.com", + provider: "google", + active: true, + createdAt: Timestamp.fromDate(new Date("2026-07-01T00:00:00Z")), + }, + { + uid: "admin-user-search-test-2", + displayName: `${PREFIX}독수리`, + email: "search-test-2@example.com", + provider: "apple", + photoUrl: "https://example.com/p.png", + active: false, + deactivatedAt: Timestamp.fromDate(new Date("2026-07-10T00:00:00Z")), + createdAt: Timestamp.fromDate(new Date("2026-07-02T00:00:00Z")), + }, +] as const; + +beforeAll(async () => { + for (const { uid, ...doc } of seedUsers) { + await firestore.doc(`users/${uid}`).set(doc); + } +}); + +describe("searchAdminUsers", () => { + it("displayName 접두어로 검색되고 날짜는 UTC ISO 문자열로 나온다", async () => { + const results = await searchAdminUsers(PREFIX, 10); + const uids = results.map((u) => u.uid); + expect(uids).toContain("admin-user-search-test-1"); + expect(uids).toContain("admin-user-search-test-2"); + + for (const dto of results) { + // Timestamp 인스턴스/_seconds 누출 금지 — 어드민 웹이 브라우저에서 그대로 소비한다. + expect(typeof dto.createdAt).toBe("string"); + expect(dto.createdAt?.endsWith("Z")).toBe(true); + } + }); + + it("비활성 유저도 검색되며 active=false 로 표시된다", async () => { + const results = await searchAdminUsers(`${PREFIX}독수리`, 10); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + uid: "admin-user-search-test-2", + active: false, + photoUrl: "https://example.com/p.png", + }); + expect(typeof results[0].deactivatedAt).toBe("string"); + }); + + it("uid 정확 일치로도 찾는다", async () => { + const results = await searchAdminUsers("admin-user-search-test-1", 10); + expect(results[0]?.uid).toBe("admin-user-search-test-1"); + expect(results[0]?.displayName).toBe(`${PREFIX}호랑이`); + }); + + it("빈 q 는 400", async () => { + await expect(searchAdminUsers(" ", 10)).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe("listAdminUsers", () => { + it("가입일 내림차순 + 커서 페이지네이션", async () => { + const first = await listAdminUsers(1); + expect(first.items).toHaveLength(1); + expect(first.cursor).toBe(first.items[0].uid); + + const second = await listAdminUsers(1, first.cursor ?? undefined); + if (second.items.length > 0) { + const firstDate = first.items[0].createdAt ?? ""; + const secondDate = second.items[0].createdAt ?? ""; + expect(secondDate <= firstDate).toBe(true); + expect(second.items[0].uid).not.toBe(first.items[0].uid); + } + }); +});