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 {
MAX_EXTRA_IMAGES,
type MerchantDetail,
type MerchantDetailSummary,
} from '@/lib/domain/merchant-detail';
import type { MerchantDetailValues } from '@/lib/domain/merchant-detail-form';
import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
/**
* 가맹몰 상세 Repository — edupay-backend origin/develop 15d409a·22f67cb 기준.
*
* ```
* GET /api/v1/mngr/voc/detail/pagination TB_COM_VOUCHER ⟕ TB_COM_VOUCHER_DTL — 가맹점 전체
* GET /api/v1/mngr/voc/detail/{frcsNo}
* POST /api/v1/mngr/voc/detail @RequestBody · 가맹점이 있어야 붙는다
* PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody
* DELETE /api/v1/mngr/voc/{frcsNo} 논리 삭제(DEL_YN)
* ```
*
* ⚠️ 백엔드 결함(보고함, 미수정) — 화면은 시안대로 두고 값을 그대로 보낸다(사용자 확정):
* 1. `MngrVocDetailVo`에 `useYn`이 없어 노출 상태가 등록·수정에서 유실되고 조회에도 안 온다.
* 2. 단건 SELECT에 소개·카테고리·영업시간·휴무일·추가이미지·순서가 없어 수정 프리필이 빈다.
* 3. 목록에 카테고리 코드·이름이 없다.
* 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다.
* 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다.
*/
const DETAIL_PATH = '/api/v1/mngr/voc/detail';
const VOC_PATH = '/api/v1/mngr/voc';
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 readYesNo(source: Record<string, unknown>, key: string): boolean | null {
const value = readString(source, key);
return value === null ? null : value.toUpperCase() === 'Y';
}
/**
* 등록일은 `d.FRST_REG_DT`가 별칭 없이 오므로 `frstRegDt`(Date → ISO 문자열 또는 epoch)로
* 받는다. 다른 화면의 `frstRegDtStr`가 있으면 그것을 우선한다.
*/
function readDate(source: Record<string, unknown>, key: string): string | null {
const formatted = readString(source, `${key}Str`);
if (formatted) return formatted.slice(0, 10);
const raw = source[key];
const date =
typeof raw === 'number'
? new Date(raw)
: typeof raw === 'string' && raw !== ''
? new Date(raw)
: null;
if (date === null || Number.isNaN(date.getTime())) return null;
return date.toISOString().slice(0, 10);
}
function toSummary(raw: unknown): MerchantDetailSummary[] {
if (!isRecord(raw)) return [];
const id = readString(raw, 'frcsNo');
const name = readString(raw, 'frcsNm');
if (!id || !name) return [];
const registeredAt = readDate(raw, 'frstRegDt');
return [
{
id,
name,
phoneNumber: readString(raw, 'rprsvTelno'),
address: readString(raw, 'frcsPostAddr'),
addressDetail: readString(raw, 'frcsDaddr'),
thumbnailFileId: readString(raw, 'thumbAtchFileId'),
thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')),
categoryCode: readString(raw, 'frcsTypeCd'),
isVisible: readYesNo(raw, 'useYn'),
registeredAt,
// 상세 행은 LEFT JOIN이라 없으면 상세 컬럼이 전부 null이다 — 등록일이 그 표지다.
hasDetail: registeredAt !== null || readString(raw, 'thumbAtchFileId') !== null,
},
];
}
/**
* 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로
* 없는 번호가 나올 때까지 HEAD로 더듬는다 — 없는 번호는 백엔드가 500으로 답한다.
*/
async function probeImageUrls(fileId: string | null): Promise<string[]> {
if (!fileId) return [];
const urls: string[] = [];
for (let fileSn = 1; fileSn <= MAX_EXTRA_IMAGES; fileSn += 1) {
const url = buildFileImageUrl(fileId, fileSn);
if (!url) break;
try {
const response = await fetch(url, { method: 'HEAD', cache: 'no-store' });
if (!response.ok) break;
} catch {
break;
}
urls.push(url);
}
return urls;
}
async function toDetail(raw: unknown): Promise<MerchantDetail | null> {
const [summary] = toSummary(raw);
if (!summary || !isRecord(raw)) return null;
const imageFileId = readString(raw, 'atchFileId');
return {
...summary,
imageUrls: await probeImageUrls(imageFileId),
postalCode: readString(raw, 'frcsPostZip'),
description: readString(raw, 'frcsCn'),
openTime: readString(raw, 'startHour'),
closeTime: readString(raw, 'endHour'),
closedDays: readString(raw, 'offDayCn'),
imageFileId,
sortOrder: readNumber(raw, 'sortOrdr'),
registeredBy: readString(raw, 'frstRgtrNm'),
modifiedAt: readDate(raw, 'lastMdfcnDt'),
modifiedBy: readString(raw, 'lastMdfrNm'),
};
}
export async function fetchMerchantDetails(
query: MerchantDetailQuery
): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${DETAIL_PATH}/pagination`, {
method: 'GET',
query: {
...(query.keyword ? { searchFrcsNm: query.keyword } : {}),
...(query.category ? { searchFrcsTypeCd: query.category } : {}),
...(query.visibility ? { searchUseYn: query.visibility } : {}),
...(query.startDate ? { searchStartDt: query.startDate } : {}),
...(query.endDate ? { searchEndDt: query.endDate } : {}),
pageIndex: query.page,
recordCountPerPage: query.pageSize,
},
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
const data = result.data;
if (data === null || data === undefined) {
return { items: [], totalCount: 0 };
}
if (!isRecord(data) || !Array.isArray(data.list)) {
throw new Error('가맹몰 상세 목록 응답의 형식이 올바르지 않습니다.');
}
return {
items: data.list.flatMap(toSummary),
totalCount:
typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
};
}
export async function fetchMerchantDetail(
frcsNo: string
): Promise<MerchantDetail | null> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${DETAIL_PATH}/${encodeURIComponent(frcsNo)}`,
{
method: 'GET',
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
}
);
if (!result.ok) {
throw new BackendRequestError(result);
}
return toDetail(result.data);
}
function toWritePayload(values: MerchantDetailValues): Record<string, unknown> {
return {
frcsNo: values.frcsNo,
frcsTypeCd: values.categoryCode,
frcsCn: values.description,
startHour: values.openTime,
endHour: values.closeTime,
offDayCn: values.closedDays,
thumbAtchFileId: values.thumbnailFileId,
atchFileId: values.imageFileId || null,
sortOrdr: values.sortOrder,
useYn: values.isVisible ? 'Y' : 'N',
// 요청 VO에 없어 버려진다(⚠️ 5). 자리가 생기면 그대로 저장된다.
voucherUseYn: values.isVoucherUsable ? '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 createMerchantDetail(
values: MerchantDetailValues
): Promise<void> {
await sendWrite(DETAIL_PATH, 'POST', toWritePayload(values));
}
export async function updateMerchantDetail(
values: MerchantDetailValues
): Promise<void> {
await sendWrite(
`${DETAIL_PATH}/${encodeURIComponent(values.frcsNo)}`,
'PUT',
toWritePayload(values)
);
}
export async function deleteMerchantDetail(frcsNo: string): Promise<void> {
await sendWrite(`${VOC_PATH}/${encodeURIComponent(frcsNo)}`, 'DELETE');
}