Add admin user search and list APIs

- GET /admin/users/search: 닉네임 접두어(Firestore orderBy+startAt/endAt) + 이메일 정확 일치(Auth 조회) + uid 정확 일치를 한 입력으로 통합 검색, uid 기준 중복 제거
- GET /admin/users/list: 가입일 내림차순 + 문서 id 커서 페이지네이션 (주문 목록과 동일 패턴)
- AdminUserDto 추가: 날짜는 UTC ISO 변환, 옵션 필드는 키 생략, streak/티어 등 lazy 보정 필드는 기존 원칙대로 제외, active 미백필 문서는 활성으로 간주
- 비활성(탈퇴) 유저도 검색 대상에 포함해 어드민이 상태를 확인할 수 있게 함
- 에뮬레이터 회귀 테스트 5건 추가 (접두어 검색·uid 일치·비활성 표시·빈 q 400·커서 페이지네이션)
This commit is contained in:
윤정민 2026-07-21 15:25:37 +09:00
parent 5508d2581a
commit d5aca61f4f
5 changed files with 249 additions and 0 deletions

View File

@ -12,6 +12,7 @@ import { Timestamp } from "firebase-admin/firestore";
import type { OrderStatus, ProductDoc } from "../types/reward"; import type { OrderStatus, ProductDoc } from "../types/reward";
import { transitionOrder } from "../services/orderService"; import { transitionOrder } from "../services/orderService";
import { invalidateRewardCatalog } from "../services/rewardCatalogService"; import { invalidateRewardCatalog } from "../services/rewardCatalogService";
import { listAdminUsers, searchAdminUsers } from "../services/adminUserService";
import { import {
toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto, toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto,
type AdminProductDto, type LedgerPageDto, type OrderPageDto, 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 === "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") { if (tail === "products/list" && req.method === "GET") {
const products = await listAllProducts(); const products = await listAllProducts();
const dto: AdminProductDto[] = products.map((p) => toAdminProductDto(p.id, p)); const dto: AdminProductDto[] = products.map((p) => toAdminProductDto(p.id, p));

View File

@ -188,6 +188,50 @@ export async function findUidByDisplayName(
return snap.empty ? null : snap.docs[0].id; 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<UserWithUid[]> {
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` , * . `createdAt` ,
* UID가 (`merge: false`). * UID가 (`merge: false`).

View File

@ -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<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 [...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: page.items.map(({ uid, user }) => toAdminUserDto(uid, user)),
cursor: page.cursor,
};
}

View File

@ -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) } : {}),
};
}

View File

@ -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);
}
});
});