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:
parent
5508d2581a
commit
d5aca61f4f
@ -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));
|
||||||
|
|||||||
@ -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`).
|
||||||
|
|||||||
67
src/services/adminUserService.ts
Normal file
67
src/services/adminUserService.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
src/types/dto/adminUserDto.ts
Normal file
40
src/types/dto/adminUserDto.ts
Normal 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) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
86
tests/services/adminUserService.test.ts
Normal file
86
tests/services/adminUserService.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user