mmday-firebase/src/handlers/rewardHandlers.ts
윤정민 6343a817af Add reward goods catalog and point-based order flow
- 상품 카탈로그(products/) 신설: 공개 필드(name·pointPrice·이미지)와 재고(total/reserved/safety)를 한 문서로 관리, 목록·상세는 60초 인메모리 캐시로 서빙
- 주문 생성 시 포인트 예약(order_reserve)과 재고 예약을 단일 트랜잭션으로 원자 처리, 주문 ID를 uid_클라이언트멱등키로 고정해 더블탭·재시도 중복 생성 차단
- 주문 상태 머신(reserved→confirmed→preparing→shipped→delivered / cancelled / refunded) 전이 검증과 전이별 capture·release·refund 역거래 원장 기록
- reward HTTP 함수 신설: 지갑·원장 조회, 상품 목록·상세, 교환 자격, 주문 생성·목록·상세·취소 API
- 관리자 라우트 추가: 상품 등록·수정, 재고 조정(가용재고 음수 방지), 주문 상태 변경, 포인트 지급·회수
- Firestore 보안규칙(지갑 본인 read, 상품·주문은 API 전용)과 orders 복합 인덱스(uid+createdAt, status+createdAt) 추가
- 상품 4종 시드(seed:rewards)와 상세 이미지 업로드(upload:reward-assets) 스크립트 추가
2026-07-16 14:25:58 +09:00

22 lines
2.2 KiB
TypeScript

import { onRequest } from "firebase-functions/https";
import { requireAuth } from "../middleware/auth";
import { sendError } from "../middleware/errors";
import { getWallet } from "../repositories/walletRepository";
import { listLedger } from "../repositories/pointLedgerRepository";
import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService";
import { computeEligibility } from "../services/eligibilityService";
import { cancelOrder, createOrder, getOrder, listOrders } from "../services/orderService";
export const reward = onRequest(async (req, res) => { try { const uid = await requireAuth(req); const path = req.path.replace(/^\/+|\/+$/g, "");
if (req.method === "GET" && path === "wallet") { res.json((await getWallet(uid)) ?? { availableBalance: 0, reservedBalance: 0, totalEarned: 0, totalSpent: 0, version: 0 }); return; }
if (req.method === "GET" && path === "ledger") { res.json(await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; }
if (req.method === "GET" && path === "products") { res.json(await getCatalog()); return; }
const product = path.match(/^products\/([^/]+)$/); if (req.method === "GET" && product) { const p = await getCatalogProduct(product[1]); if (!p) { res.status(404).json({ error: "PRODUCT_NOT_FOUND" }); return; } res.json(p); return; }
if (req.method === "GET" && path === "eligibility") { res.json(await computeEligibility(uid)); return; }
if (req.method === "POST" && path === "orders") { res.status(201).json(await createOrder(uid, req.body)); return; }
if (req.method === "GET" && path === "orders") { res.json(await listOrders(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; }
const order = path.match(/^orders\/([^/]+)$/); if (req.method === "GET" && order) { const o = await getOrder(order[1]); if (!o || o.uid !== uid) { res.status(404).json({ error: "ORDER_NOT_FOUND" }); return; } res.json(o); return; }
const cancel = path.match(/^orders\/([^/]+)\/cancel$/); if (req.method === "POST" && cancel) { res.json(await cancelOrder(uid, cancel[1])); return; }
res.status(404).json({ error: "not found" });
} catch (err) { sendError(res, err); } });