import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import { buildFileImageUrl } from '@/lib/data/repositories/file-repository'; import { joinProductBadges, parseProductBadges, type MerchantProduct, } from '@/lib/domain/merchant-product'; import type { MerchantProductValues } from '@/lib/domain/merchant-product-form'; /** * 가맹몰 메뉴 Repository — edupay-backend origin/feature/voc d784bb6 기준(2026-09-16). * * ``` * GET /api/v1/mngr/voc/product/list/{frcsNo} 메뉴 전체(페이징 없음) — 상세와 INNER JOIN * GET /api/v1/mngr/voc/product/{frcsProductSn} 없으면 성공 봉투에 data: null (develop은 NOT_FOUND) * POST /api/v1/mngr/voc/product @RequestBody · 가맹점만 있으면 받는다 * PUT /api/v1/mngr/voc/product/{frcsProductSn} @RequestBody · 전 컬럼 덮어씀 * DELETE /api/v1/mngr/voc/product/{frcsProductSn} 논리 삭제(DEL_YN) * ``` * * 등록은 상세 행이 없어도 통과하지만 목록·단건·수정·삭제는 상세와 INNER JOIN이라 상세를 저장하기 * 전에 넣은 메뉴는 보이지도 고쳐지지도 않는다 — 그래서 화면은 상세를 먼저 저장하게 한다. * 목록 SQL에 ORDER BY가 없어 노출 순서(낮을수록 상단)는 여기서 정렬한다. */ const PRODUCT_PATH = '/api/v1/mngr/voc/product'; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function readString(source: Record, key: string): string | null { const value = source[key]; if (typeof value === 'string' && value !== '') return value; if (typeof value === 'number') return String(value); return null; } function readNumber(source: Record, key: string): number | null { const value = source[key]; if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function toProduct(raw: unknown): MerchantProduct[] { if (!isRecord(raw)) return []; const id = readString(raw, 'frcsProductSn'); const frcsNo = readString(raw, 'frcsNo'); if (!id || !frcsNo) return []; return [ { id, frcsNo, name: readString(raw, 'frcsProductSj') ?? '', price: readNumber(raw, 'frcsProductPrice'), description: readString(raw, 'frcsProductCn'), badges: parseProductBadges(readString(raw, 'frcsBadge')), imageFileId: readString(raw, 'atchFileId'), imageUrl: buildFileImageUrl(readString(raw, 'atchFileId')), sortOrder: readNumber(raw, 'sortOrdr'), isVisible: (readString(raw, 'useYn') ?? 'Y').toUpperCase() !== 'N', }, ]; } export async function fetchMerchantProducts(frcsNo: string): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch( `${PRODUCT_PATH}/list/${encodeURIComponent(frcsNo)}`, { method: 'GET', accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, } ); if (!result.ok) { throw new BackendRequestError(result); } const products = Array.isArray(result.data) ? result.data.flatMap(toProduct) : []; return products.sort(compareProductOrder); } /** 노출 순서 오름차순, 순서가 없는 메뉴는 뒤로, 같으면 등록된 순(번호)대로. */ function compareProductOrder(a: MerchantProduct, b: MerchantProduct): number { const orderA = a.sortOrder ?? Number.POSITIVE_INFINITY; const orderB = b.sortOrder ?? Number.POSITIVE_INFINITY; if (orderA !== orderB) return orderA - orderB; return a.id.localeCompare(b.id, undefined, { numeric: true }); } export async function fetchMerchantProduct( productSn: string ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch( `${PRODUCT_PATH}/${encodeURIComponent(productSn)}`, { method: 'GET', accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, } ); if (!result.ok) { // 없는 메뉴는 404 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다. if (result.code === 404) return null; throw new BackendRequestError(result); } return toProduct(result.data)[0] ?? null; } function toWritePayload(values: MerchantProductValues): Record { return { frcsNo: values.frcsNo, frcsProductSj: values.name, frcsProductPrice: values.price, frcsProductCn: values.description, frcsBadge: joinProductBadges(values.badges), atchFileId: values.imageFileId || null, sortOrdr: values.sortOrder, useYn: values.isVisible ? 'Y' : 'N', }; } async function sendWrite( path: string, method: 'POST' | 'PUT' | 'DELETE', body?: unknown ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method, ...(body === undefined ? {} : { body }), accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } } export async function createMerchantProduct( values: MerchantProductValues ): Promise { await sendWrite(PRODUCT_PATH, 'POST', toWritePayload(values)); } export async function updateMerchantProduct( productSn: string, values: MerchantProductValues ): Promise { await sendWrite( `${PRODUCT_PATH}/${encodeURIComponent(productSn)}`, 'PUT', toWritePayload(values) ); } export async function deleteMerchantProduct(productSn: string): Promise { await sendWrite(`${PRODUCT_PATH}/${encodeURIComponent(productSn)}`, 'DELETE'); }