Add admin console read APIs, claim script, and CORS hedge

- 어드민 웹(fanit_admin) 지원: GET products/list(비노출 포함 전체 상품, AdminProductDto)·GET orders/list(전 유저 주문, createdAt desc 커서 페이지네이션) 추가 — Hosting rewrite·직접 호출 양쪽에서 일관되는 2세그먼트 라우트
- 지갑·원장 어드민 응답도 DTO로 감싸 Timestamp({_seconds}) 유출 제거
- admin 함수에 단일 origin CORS 헤지 추가(OPTIONS preflight를 requireAdmin보다 먼저 처리, 에러 응답에도 헤더 적용)
- scripts/set-admin-claim.ts로 운영자 admin 클레임 부여(부여 후 재로그인 필요)
- Storage 규칙: write만 admin 클레임으로 제한, read는 불변(앱 이미지 표시 계약 유지)
- 신규 조회·DTO vitest 테스트 추가
This commit is contained in:
윤정민 2026-07-21 13:57:42 +09:00
parent fde21bf8c3
commit c635f16952
9 changed files with 293 additions and 6 deletions

View File

@ -0,0 +1,27 @@
/**
* admin custom claim(`admin: true`) .
*
* 사용법: npx tsx scripts/set-admin-claim.ts <email>
*/
import "./_bootstrap";
import { auth } from "../src/firebase";
async function main() {
const email = process.argv[2];
if (!email) {
console.error("사용법: npx tsx scripts/set-admin-claim.ts <email>");
process.exitCode = 1;
return;
}
const user = await auth.getUserByEmail(email);
await auth.setCustomUserClaims(user.uid, { admin: true });
console.log(`admin claim 부여 완료: ${email} (uid: ${user.uid})`);
console.log("반영을 위해 재로그인(또는 강제 토큰 갱신)이 필요합니다.");
}
main().catch((e) => {
console.error(e);
process.exitCode = 1;
});

View File

@ -5,13 +5,35 @@ import { markGameEnded } from "../services/gameResultService";
import { adminPointChange } from "../services/adminPointService"; import { adminPointChange } from "../services/adminPointService";
import { getWallet } from "../repositories/walletRepository"; import { getWallet } from "../repositories/walletRepository";
import { listLedger } from "../repositories/pointLedgerRepository"; import { listLedger } from "../repositories/pointLedgerRepository";
import { listAllProducts } from "../repositories/productRepository";
import { listAllOrders } from "../repositories/orderRepository";
import { firestore } from "../firebase"; import { firestore } from "../firebase";
import { Timestamp } from "firebase-admin/firestore"; 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 {
toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto,
type AdminProductDto, type LedgerPageDto, type OrderPageDto,
} from "../types/dto/rewardDto";
// TODO: 어드민 웹 Hosting 사이트 도메인이 확정되면 실제 값으로 교체.
const ADMIN_ORIGIN = "https://fanit-admin.web.app";
/** admin 함수 전용 단일 origin CORS 헤지. rewrite 경로 장애 시 직접 호출 대체 경로를 위해 둔다. */
function applyCors(req: { get(name: string): string | undefined }, res: { set(name: string, value: string): void }) {
if (req.get("origin") === ADMIN_ORIGIN) {
res.set("Access-Control-Allow-Origin", ADMIN_ORIGIN);
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
res.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
}
}
export const admin = onRequest(async (req, res) => { export const admin = onRequest(async (req, res) => {
applyCors(req, res);
// preflight에는 Authorization 헤더가 없어 requireAdmin보다 먼저 처리해야 한다.
if (req.method === "OPTIONS") { res.status(204).send(""); return; }
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/"); const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
const tail = segs.slice(-2).join("/"); const tail = segs.slice(-2).join("/");
@ -19,8 +41,9 @@ export const admin = onRequest(async (req, res) => {
const adminUid = await requireAdmin(req); const adminUid = await requireAdmin(req);
if ((tail === "points/credit" || tail === "points/debit") && req.method === "POST") { const { uid, amount, clientIdempotencyKey, reason, reversalOf } = req.body ?? {}; res.json(await adminPointChange(uid, amount, tail.endsWith("credit") ? "credit" : "debit", clientIdempotencyKey, reason, adminUid, reversalOf)); return; } if ((tail === "points/credit" || tail === "points/debit") && req.method === "POST") { const { uid, amount, clientIdempotencyKey, reason, reversalOf } = req.body ?? {}; res.json(await adminPointChange(uid, amount, tail.endsWith("credit") ? "credit" : "debit", clientIdempotencyKey, reason, adminUid, reversalOf)); return; }
if (tail === "points/wallet" && req.method === "GET") { res.json(await getWallet(String(req.query.uid))); return; } // 지갑/원장도 DTO 를 거쳐 Timestamp 가 {_seconds,_nanoseconds} 로 새지 않게 한다 (어드민 웹이 브라우저에서 소비).
if (tail === "points/ledger" && req.method === "GET") { res.json(await listLedger(String(req.query.uid), Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; } if (tail === "points/wallet" && req.method === "GET") { const wallet = await getWallet(String(req.query.uid)); res.json(wallet ? toWalletDto(wallet) : null); return; }
if (tail === "points/ledger" && req.method === "GET") { const uid = String(req.query.uid); const page = await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined); const dto: LedgerPageDto = { items: page.items.map((entry) => toLedgerEntryDto(entry.id, uid, entry)), cursor: page.cursor }; res.json(dto); return; }
if (tail === "product/upsert" && req.method === "POST") { if (tail === "product/upsert" && req.method === "POST") {
const { const {
id, name, pointPrice, active, redeemable, displayOrder, id, name, pointPrice, active, redeemable, displayOrder,
@ -75,6 +98,26 @@ 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 === "products/list" && req.method === "GET") {
const products = await listAllProducts();
const dto: AdminProductDto[] = products.map((p) => toAdminProductDto(p.id, p));
res.json(dto);
return;
}
if (tail === "orders/list" && req.method === "GET") {
const limit = Number(req.query.limit) || 20;
const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined;
const status = typeof req.query.status === "string" ? (req.query.status as OrderStatus) : undefined;
const page = await listAllOrders(limit, cursor, status);
const dto: OrderPageDto = {
items: page.items.map((o) => toOrderDto(o.id, o)),
cursor: page.cursor,
};
res.json(dto);
return;
}
if (tail === "game/end" && req.method === "POST") { if (tail === "game/end" && req.method === "POST") {
const { gameId, winningTeamCode } = req.body ?? {}; const { gameId, winningTeamCode } = req.body ?? {};
const result = await markGameEnded(gameId, winningTeamCode); const result = await markGameEnded(gameId, winningTeamCode);

View File

@ -1,5 +1,14 @@
import { firestore } from "../firebase"; import { firestore } from "../firebase";
import type { OrderDoc } from "../types/reward"; import type { OrderDoc, OrderStatus } from "../types/reward";
export function orderRef(id: string) { return firestore.doc(`orders/${id}`); } export function orderRef(id: string) { return firestore.doc(`orders/${id}`); }
export async function listOrders(uid: string, limit = 20, cursor?: string) { let q = firestore.collection("orders").where("uid", "==", uid).orderBy("createdAt", "desc").limit(Math.min(Math.max(limit, 1), 100)); if (cursor) { const c = await orderRef(cursor).get(); if (c.exists) q = q.startAfter(c) as typeof q; } const s = await q.get(); return { items: s.docs.map((d) => ({ id: d.id, ...(d.data() as OrderDoc) })), cursor: s.docs.length ? s.docs[s.docs.length - 1].id : null }; } export async function listOrders(uid: string, limit = 20, cursor?: string) { let q = firestore.collection("orders").where("uid", "==", uid).orderBy("createdAt", "desc").limit(Math.min(Math.max(limit, 1), 100)); if (cursor) { const c = await orderRef(cursor).get(); if (c.exists) q = q.startAfter(c) as typeof q; } const s = await q.get(); return { items: s.docs.map((d) => ({ id: d.id, ...(d.data() as OrderDoc) })), cursor: s.docs.length ? s.docs[s.docs.length - 1].id : null }; }
/** 어드민 전용 — uid 필터 없이 전 유저 주문을 조회한다. status 필터는 기존 status+createdAt 복합 인덱스를 재사용한다. */
export async function listAllOrders(limit = 20, cursor?: string, status?: OrderStatus) {
let q = (status ? firestore.collection("orders").where("status", "==", status) : firestore.collection("orders"))
.orderBy("createdAt", "desc")
.limit(Math.min(Math.max(limit, 1), 100));
if (cursor) { const c = await orderRef(cursor).get(); if (c.exists) q = q.startAfter(c) as typeof q; }
const s = await q.get();
return { items: s.docs.map((d) => ({ id: d.id, ...(d.data() as OrderDoc) })), cursor: s.docs.length ? s.docs[s.docs.length - 1].id : null };
}
export async function getOrder(id: string) { const s = await orderRef(id).get(); return s.exists ? { id, ...(s.data() as OrderDoc) } : null; } export async function getOrder(id: string) { const s = await orderRef(id).get(); return s.exists ? { id, ...(s.data() as OrderDoc) } : null; }

View File

@ -2,4 +2,6 @@ import { firestore } from "../firebase";
import type { ProductDoc } from "../types/reward"; import type { ProductDoc } from "../types/reward";
export function productRef(id: string) { return firestore.doc(`products/${id}`); } export function productRef(id: string) { return firestore.doc(`products/${id}`); }
export async function listProducts(): Promise<Array<ProductDoc & { id: string }>> { const s = await firestore.collection("products").where("active", "==", true).where("redeemable", "==", true).get(); return s.docs.map((d) => ({ id: d.id, ...(d.data() as ProductDoc) })); } export async function listProducts(): Promise<Array<ProductDoc & { id: string }>> { const s = await firestore.collection("products").where("active", "==", true).where("redeemable", "==", true).get(); return s.docs.map((d) => ({ id: d.id, ...(d.data() as ProductDoc) })); }
/** 어드민 전용 — active/redeemable 필터 없이 전체 상품을 반환한다. */
export async function listAllProducts(): Promise<Array<ProductDoc & { id: string }>> { const s = await firestore.collection("products").get(); return s.docs.map((d) => ({ id: d.id, ...(d.data() as ProductDoc) })); }
export async function getProduct(id: string): Promise<(ProductDoc & { id: string }) | null> { const s = await productRef(id).get(); return s.exists ? { id, ...(s.data() as ProductDoc) } : null; } export async function getProduct(id: string): Promise<(ProductDoc & { id: string }) | null> { const s = await productRef(id).get(); return s.exists ? { id, ...(s.data() as ProductDoc) } : null; }

View File

@ -1,7 +1,7 @@
import { toIso, toIsoOrUndefined } from "./iso"; import { toIso, toIsoOrUndefined } from "./iso";
import { OP_BY_TYPE } from "../points"; import { OP_BY_TYPE } from "../points";
import type { PointLedgerEntry, PointLedgerType, PointOperation, WalletDoc } from "../points"; import type { PointLedgerEntry, PointLedgerType, PointOperation, WalletDoc } from "../points";
import type { OrderDoc, OrderStatus } from "../reward"; import type { OrderDoc, OrderStatus, ProductDoc } from "../reward";
/** /**
* reward DTO. * reward DTO.
@ -129,6 +129,12 @@ export interface ProductDetailDto extends ProductSummaryDto {
options?: ProductOptionDto[]; options?: ProductOptionDto[];
} }
/** 어드민 상품 목록 DTO — active/redeemable 필터 없이 전체 상품을 노출한다. */
export interface AdminProductDto extends ProductDetailDto {
createdAt: string;
updatedAt: string;
}
export interface EligibilityDto { export interface EligibilityDto {
eligible: boolean; eligible: boolean;
reasons: string[]; reasons: string[];
@ -216,6 +222,31 @@ export function toLedgerEntryDto(
}; };
} }
/**
* DTO. active/redeemable
* ( ).
*/
export function toAdminProductDto(id: string, doc: ProductDoc): AdminProductDto {
return {
id,
name: doc.name,
pointPrice: doc.pointPrice,
active: doc.active,
redeemable: doc.redeemable,
displayOrder: doc.displayOrder,
mainImages: doc.mainImages,
detailImages: doc.detailImages,
...(doc.options?.length
? {
optionLabel: doc.optionLabel ?? "옵션",
options: doc.options.map((o) => ({ id: o.id, name: o.name })),
}
: {}),
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
/** /**
* DTO. POST /orders, GET /orders, GET /orders/:id * DTO. POST /orders, GET /orders, GET /orders/:id
* statusHistory . * statusHistory .

View File

@ -3,7 +3,8 @@ rules_version = '2';
service firebase.storage { service firebase.storage {
match /b/{bucket}/o { match /b/{bucket}/o {
match /{allPaths=**} { match /{allPaths=**} {
allow read, write: if request.auth != null; allow read: if request.auth != null;
allow write: if request.auth != null && request.auth.token.admin == true;
} }
} }
} }

View File

@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase";
import { listAllOrders } from "../../src/repositories/orderRepository";
import type { OrderDoc } from "../../src/types/reward";
function makeOrder(uid: string, createdAt: Timestamp, overrides: Partial<OrderDoc> = {}): OrderDoc {
return {
uid,
items: [{ productId: "p1", qty: 1, pointPrice: 500, name: "굿즈" }],
totalPoints: 500,
recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" },
status: "reserved",
clientIdempotencyKey: `key-${uid}-${createdAt.toMillis()}`,
reserveLedgerTxIds: [],
orderedAt: createdAt,
statusHistory: [{ status: "reserved", at: createdAt, actor: uid }],
createdAt,
updatedAt: createdAt,
...overrides,
};
}
describe("orderRepository.listAllOrders", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("orders"));
});
it("uid 필터 없이 서로 다른 유저의 주문을 모두 createdAt desc로 반환한다", async () => {
const t1 = Timestamp.fromDate(new Date("2026-07-01T00:00:00.000Z"));
const t2 = Timestamp.fromDate(new Date("2026-07-02T00:00:00.000Z"));
const t3 = Timestamp.fromDate(new Date("2026-07-03T00:00:00.000Z"));
await firestore.doc("orders/o1").set(makeOrder("uid-a", t1));
await firestore.doc("orders/o2").set(makeOrder("uid-b", t2));
await firestore.doc("orders/o3").set(makeOrder("uid-a", t3));
const page = await listAllOrders(20);
expect(page.items.map((o) => o.id)).toEqual(["o3", "o2", "o1"]);
expect(page.items.map((o) => o.uid).sort()).toEqual(["uid-a", "uid-a", "uid-b"].sort());
});
it("cursor 기반 페이지네이션이 동작한다", async () => {
const t1 = Timestamp.fromDate(new Date("2026-07-01T00:00:00.000Z"));
const t2 = Timestamp.fromDate(new Date("2026-07-02T00:00:00.000Z"));
const t3 = Timestamp.fromDate(new Date("2026-07-03T00:00:00.000Z"));
await firestore.doc("orders/o1").set(makeOrder("uid-a", t1));
await firestore.doc("orders/o2").set(makeOrder("uid-b", t2));
await firestore.doc("orders/o3").set(makeOrder("uid-a", t3));
const firstPage = await listAllOrders(2);
expect(firstPage.items.map((o) => o.id)).toEqual(["o3", "o2"]);
expect(firstPage.cursor).toBe("o2");
const secondPage = await listAllOrders(2, firstPage.cursor ?? undefined);
expect(secondPage.items.map((o) => o.id)).toEqual(["o1"]);
expect(secondPage.cursor).toBe("o1");
});
it("status 필터를 걸면 해당 상태 주문만 반환한다 (기존 status+createdAt 인덱스 재사용)", async () => {
const t1 = Timestamp.fromDate(new Date("2026-07-01T00:00:00.000Z"));
const t2 = Timestamp.fromDate(new Date("2026-07-02T00:00:00.000Z"));
await firestore.doc("orders/o1").set(makeOrder("uid-a", t1, { status: "reserved" }));
await firestore.doc("orders/o2").set(makeOrder("uid-b", t2, { status: "confirmed" }));
const page = await listAllOrders(20, undefined, "confirmed");
expect(page.items.map((o) => o.id)).toEqual(["o2"]);
});
});

View File

@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase";
import { listAllProducts, listProducts } from "../../src/repositories/productRepository";
import type { ProductDoc } from "../../src/types/reward";
const now = Timestamp.now();
function makeProduct(overrides: Partial<ProductDoc> = {}): ProductDoc {
return {
name: "상품",
pointPrice: 1000,
active: true,
redeemable: true,
displayOrder: 0,
mainImages: [],
detailImages: [],
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe("productRepository", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("products"));
});
describe("listAllProducts", () => {
it("active/redeemable 필터 없이 비노출 상품도 포함해 전체를 반환한다", async () => {
await firestore.doc("products/exposed").set(makeProduct({ active: true, redeemable: true }));
await firestore.doc("products/hidden-inactive").set(makeProduct({ active: false, redeemable: true }));
await firestore.doc("products/hidden-non-redeemable").set(makeProduct({ active: true, redeemable: false }));
const all = await listAllProducts();
expect(all.map((p) => p.id).sort()).toEqual([
"exposed",
"hidden-inactive",
"hidden-non-redeemable",
]);
});
it("listProducts(소비자용)는 여전히 active+redeemable 필터가 걸려 있다", async () => {
await firestore.doc("products/exposed").set(makeProduct({ active: true, redeemable: true }));
await firestore.doc("products/hidden").set(makeProduct({ active: false, redeemable: true }));
const consumerList = await listProducts();
expect(consumerList.map((p) => p.id)).toEqual(["exposed"]);
});
});
});

View File

@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { import {
EMPTY_WALLET_DTO, EMPTY_WALLET_DTO,
toAdminProductDto,
toLedgerEntryDto, toLedgerEntryDto,
toOrderDto, toOrderDto,
toWalletDto, toWalletDto,
} from "../../src/types/dto/rewardDto"; } from "../../src/types/dto/rewardDto";
import { PointLedgerType, type PointLedgerEntry, type WalletDoc } from "../../src/types/points"; import { PointLedgerType, type PointLedgerEntry, type WalletDoc } from "../../src/types/points";
import type { OrderDoc } from "../../src/types/reward"; import type { OrderDoc, ProductDoc } from "../../src/types/reward";
const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
@ -214,3 +215,54 @@ describe("toOrderDto", () => {
expect(json.items[0]).not.toHaveProperty("optionName"); expect(json.items[0]).not.toHaveProperty("optionName");
}); });
}); });
describe("toAdminProductDto", () => {
const baseProduct: ProductDoc = {
name: "아크릴 키링",
pointPrice: 3000,
active: false,
redeemable: false,
displayOrder: 1,
mainImages: ["https://example.com/main.png"],
detailImages: ["https://example.com/detail.png"],
createdAt: ts("2026-01-01T00:00:00.000Z"),
updatedAt: ts("2026-07-20T05:30:00.000Z"),
};
it("active/redeemable 이 false 인 비노출 상품도 그대로 응답에 포함한다", () => {
const dto = toAdminProductDto("acrylic-keyring", baseProduct);
expect(dto.active).toBe(false);
expect(dto.redeemable).toBe(false);
});
it("Timestamp 를 유출하지 않고 UTC ISO(Z) 문자열로 내보낸다", () => {
const dto = toAdminProductDto("acrylic-keyring", baseProduct);
expectNoTimestampLeak(dto);
expect(dto.createdAt).toMatch(UTC_ISO);
expect(dto.updatedAt).toMatch(UTC_ISO);
expect(dto.createdAt).toBe("2026-01-01T00:00:00.000Z");
expect(dto.updatedAt).toBe("2026-07-20T05:30:00.000Z");
});
it("옵션이 있으면 optionLabel/options 를 포함한다", () => {
const withOptions: ProductDoc = {
...baseProduct,
optionLabel: "색상",
options: [{ id: "blue", name: "블루" }],
};
const json = wire(toAdminProductDto("p1", withOptions));
expect(json.optionLabel).toBe("색상");
expect(json.options).toEqual([{ id: "blue", name: "블루" }]);
});
it("옵션이 없으면 optionLabel/options 키가 응답에서 사라진다", () => {
const json = wire(toAdminProductDto("p1", baseProduct));
expect(json).not.toHaveProperty("optionLabel");
expect(json).not.toHaveProperty("options");
});
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
const polluted = { ...baseProduct, internalOnly: "secret" } as ProductDoc;
expect(wire(toAdminProductDto("p1", polluted))).not.toHaveProperty("internalOnly");
});
});