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:
parent
fde21bf8c3
commit
c635f16952
27
scripts/set-admin-claim.ts
Normal file
27
scripts/set-admin-claim.ts
Normal 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;
|
||||
});
|
||||
@ -5,13 +5,35 @@ import { markGameEnded } from "../services/gameResultService";
|
||||
import { adminPointChange } from "../services/adminPointService";
|
||||
import { getWallet } from "../repositories/walletRepository";
|
||||
import { listLedger } from "../repositories/pointLedgerRepository";
|
||||
import { listAllProducts } from "../repositories/productRepository";
|
||||
import { listAllOrders } from "../repositories/orderRepository";
|
||||
import { firestore } from "../firebase";
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import type { OrderStatus, ProductDoc } from "../types/reward";
|
||||
import { transitionOrder } from "../services/orderService";
|
||||
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) => {
|
||||
applyCors(req, res);
|
||||
// preflight에는 Authorization 헤더가 없어 requireAdmin보다 먼저 처리해야 한다.
|
||||
if (req.method === "OPTIONS") { res.status(204).send(""); return; }
|
||||
|
||||
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
|
||||
const tail = segs.slice(-2).join("/");
|
||||
|
||||
@ -19,8 +41,9 @@ export const admin = onRequest(async (req, res) => {
|
||||
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/wallet" && req.method === "GET") { res.json(await getWallet(String(req.query.uid))); return; }
|
||||
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; }
|
||||
// 지갑/원장도 DTO 를 거쳐 Timestamp 가 {_seconds,_nanoseconds} 로 새지 않게 한다 (어드민 웹이 브라우저에서 소비).
|
||||
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") {
|
||||
const {
|
||||
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 === "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") {
|
||||
const { gameId, winningTeamCode } = req.body ?? {};
|
||||
const result = await markGameEnded(gameId, winningTeamCode);
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
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 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; }
|
||||
|
||||
@ -2,4 +2,6 @@ import { firestore } from "../firebase";
|
||||
import type { ProductDoc } from "../types/reward";
|
||||
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) })); }
|
||||
/** 어드민 전용 — 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; }
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { toIso, toIsoOrUndefined } from "./iso";
|
||||
import { OP_BY_TYPE } 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.
|
||||
@ -129,6 +129,12 @@ export interface ProductDetailDto extends ProductSummaryDto {
|
||||
options?: ProductOptionDto[];
|
||||
}
|
||||
|
||||
/** 어드민 상품 목록 DTO — active/redeemable 필터 없이 전체 상품을 노출한다. */
|
||||
export interface AdminProductDto extends ProductDetailDto {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EligibilityDto {
|
||||
eligible: boolean;
|
||||
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 세 경로가 공유하고
|
||||
* statusHistory 중첩까지 같은 규칙을 타야 해서 이것만 함수로 뺐다.
|
||||
|
||||
@ -3,7 +3,8 @@ rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
tests/repositories/orderRepository.test.ts
Normal file
71
tests/repositories/orderRepository.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
51
tests/repositories/productRepository.test.ts
Normal file
51
tests/repositories/productRepository.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import {
|
||||
EMPTY_WALLET_DTO,
|
||||
toAdminProductDto,
|
||||
toLedgerEntryDto,
|
||||
toOrderDto,
|
||||
toWalletDto,
|
||||
} from "../../src/types/dto/rewardDto";
|
||||
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$/;
|
||||
|
||||
@ -214,3 +215,54 @@ describe("toOrderDto", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user