File name
Commit message
Commit date
File name
Commit message
Commit date
09-03
File name
Commit message
Commit date
import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
import type { PointStandard } from '@/lib/domain/point-standard';
import type { PointStandardQuery } from '@/lib/domain/point-standard-query';
import type { PointStandardValues } from '@/lib/domain/point-standard-form';
/**
* 포인트 지급기준(포인트 정책) Repository — 백엔드 `TB_COM_EVENT_POINT`.
*
* ```
* GET /api/v1/mngr/point/policy/pagination 목록
* POST /api/v1/mngr/point/policy 어노테이션 없음 → form
* PUT /api/v1/mngr/point/policy/{eventPtSn} @RequestBody(JSON)
* DELETE /api/v1/mngr/point/policy/{eventPtSn} soft delete(DEL_YN)
* ```
*
* 등록은 form, 수정은 JSON으로 갈린다 — 콘텐츠·퀴즈와 같은 규약이다.
*
* 이슈: 경로가 `/mngr/point`에서 `/mngr/point/policy`로 옮겨졌다(2026-08-21, 누적포인트
* `/point/stats`가 생기면서 갈라졌다). 옮기기 전 경로로 부르면 404다.
*
* ⚠️ 백엔드 결함(보고함, 미수정)
* 1. **`MngrPointPolicyVo`에 `eventPtSn` 말고는 필드가 없다.** 목록 SQL은 EVENT_CD·
* GRANT_POINT_AMT·DAY_LMT_POINT_AMT·DUPL_YN·EVENT_EXPLN·USE_YN을 select하지만 담을 속성이
* 없어 조용히 버려진다 — 지금 목록은 번호와 등록일만 온다. 아래 매핑은 필드가 채워지면
* 바로 동작하도록 미리 맞춰 두었다.
* 2. **등록이 실패한다.** 같은 이유로 INSERT의 `#{eventCd}` 등이 VO에 없어 MyBatis가
* `There is no getter for property named 'eventCd'`로 끊는다. INSERT 컬럼 목록에 PK인
* `EVENT_PT_SN`을 넣고 `#{eventPtSn}`(항상 null)을 바인딩하는 것도 함께 봐야 한다.
* 3. **수정·삭제가 항상 실패한다.** `MngrPointPolicyServiceImpl.selectOne`이 `return null;`
* 스텁이라 컨트롤러가 늘 "포인트 정책 조회에 실패했습니다"로 끊는다.
* 4. `selectOne` SQL은 `includeSql`이 이미 `WHERE`를 갖고 있는데 뒤에 `WHERE`를 또 붙이고,
* 없는 별칭 `m.`을 쓴다 — 스텁이 걷히면 이 SQL도 함께 고쳐야 한다.
* 5. 목록에 검색 조건이 없다(`MngrPointPolicySearchVo`가 비어 있다). 화면의 검색어는 받아온
* 뒤 여기서 거른다.
*/
const POINT_PATH = '/api/v1/mngr/point/policy';
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, key: string): string {
const value = source[key];
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
return '';
}
function readNumber(source: Record<string, unknown>, key: string): number {
const value = source[key];
if (typeof value === 'number') return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function toPointStandard(raw: unknown): PointStandard {
if (!isRecord(raw)) {
throw new Error('포인트 정책 응답 항목의 형식이 올바르지 않습니다.');
}
const id = readString(raw, 'eventPtSn');
if (!id) {
throw new Error('포인트 정책 응답에 eventPtSn이 없습니다.');
}
return {
id,
eventCode: readString(raw, 'eventCd'),
point: readNumber(raw, 'grantPointAmt'),
limitPoint: readNumber(raw, 'dayLmtPointAmt'),
isDuplicable: readString(raw, 'duplYn') !== 'N',
description: readString(raw, 'eventExpln'),
isActive: readString(raw, 'useYn') !== 'N',
updatedAt:
readString(raw, 'lastMdfcnDtStr') || readString(raw, 'frstRegDtStr'),
};
}
async function requestList(
pageIndex: number,
recordCountPerPage: number
): Promise<{ items: PointStandard[]; reportedTotalCount: number }> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${POINT_PATH}/pagination`, {
method: 'GET',
query: { pageIndex, recordCountPerPage },
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
const data = result.data;
if (data === null || data === undefined) {
return { items: [], reportedTotalCount: 0 };
}
if (!isRecord(data) || !Array.isArray(data.list)) {
throw new Error('포인트 정책 목록 응답의 형식이 올바르지 않습니다.');
}
return {
items: data.list.map(toPointStandard),
reportedTotalCount:
typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
};
}
function matchesKeyword(standard: PointStandard, keyword: string): boolean {
const needle = keyword.trim().toLowerCase();
return (
standard.eventCode.toLowerCase().includes(needle) ||
standard.description.toLowerCase().includes(needle)
);
}
/** 검색어가 없으면 백엔드 페이징을 그대로 쓰고, 있으면 전체를 받아 여기서 거른다(결함 5). */
const KEYWORD_SCAN_LIMIT = 500;
export async function fetchPointStandards(
query: PointStandardQuery
): Promise<{ items: PointStandard[]; totalCount: number }> {
if (!query.keyword.trim()) {
const { items, reportedTotalCount } = await requestList(
query.page,
query.pageSize
);
const confirmed = (query.page - 1) * query.pageSize + items.length;
return { items, totalCount: Math.max(reportedTotalCount, confirmed) };
}
const { items } = await requestList(1, KEYWORD_SCAN_LIMIT);
const filtered = items.filter((item) => matchesKeyword(item, query.keyword));
const start = (query.page - 1) * query.pageSize;
return {
items: filtered.slice(start, start + query.pageSize),
totalCount: filtered.length,
};
}
type PointStandardPayload = {
eventCd: string;
grantPointAmt: string;
dayLmtPointAmt: string;
duplYn: string;
eventExpln: string;
useYn: string;
};
function buildPayload(values: PointStandardValues): PointStandardPayload {
return {
eventCd: values.eventCode,
grantPointAmt: String(values.point),
dayLmtPointAmt: String(values.limitPoint),
duplYn: values.isDuplicable ? 'Y' : 'N',
eventExpln: values.description,
useYn: values.isActive ? 'Y' : 'N',
};
}
async function sendWrite(
path: string,
method: 'POST' | 'PUT' | 'DELETE',
body?: unknown,
form?: Record<string, string | number | undefined>
): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(path, {
method,
...(body === undefined ? {} : { body }),
...(form === undefined ? {} : { form }),
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}
/**
* 등록은 `@RequestBody`가 없어 form으로 간다.
*
* TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 수정과 같은 JSON으로 통일한다. 반영 여부는
* `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 확인한다.
*/
export async function createPointStandard(
values: PointStandardValues
): Promise<void> {
await sendWrite(POINT_PATH, 'POST', undefined, { ...buildPayload(values) });
}
export async function updatePointStandard(
values: PointStandardValues
): Promise<void> {
await sendWrite(
`${POINT_PATH}/${encodeURIComponent(values.id)}`,
'PUT',
buildPayload(values)
);
}
export async function deletePointStandard(id: string): Promise<void> {
await sendWrite(`${POINT_PATH}/${encodeURIComponent(id)}`, 'DELETE');
}