File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
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<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, 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<string, unknown>, 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<MerchantProduct[]> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${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<MerchantProduct | null> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${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<string, unknown> {
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<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(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<void> {
await sendWrite(PRODUCT_PATH, 'POST', toWritePayload(values));
}
export async function updateMerchantProduct(
productSn: string,
values: MerchantProductValues
): Promise<void> {
await sendWrite(
`${PRODUCT_PATH}/${encodeURIComponent(productSn)}`,
'PUT',
toWritePayload(values)
);
}
export async function deleteMerchantProduct(productSn: string): Promise<void> {
await sendWrite(`${PRODUCT_PATH}/${encodeURIComponent(productSn)}`, 'DELETE');
}