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/pagination 목록 * POST /api/v1/mngr/point 어노테이션 없음 → form * PUT /api/v1/mngr/point/{eventPtSn} @RequestBody(JSON) * DELETE /api/v1/mngr/point/{eventPtSn} soft delete(DEL_YN) * ``` * * 등록은 form, 수정은 JSON으로 갈린다 — 콘텐츠·퀴즈와 같은 규약이다. * * ⚠️ 백엔드 결함(보고함, 미수정) * 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'; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function readString(source: Record, 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, 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(`${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 ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(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 { await sendWrite(POINT_PATH, 'POST', undefined, { ...buildPayload(values) }); } export async function updatePointStandard( values: PointStandardValues ): Promise { await sendWrite( `${POINT_PATH}/${encodeURIComponent(values.id)}`, 'PUT', buildPayload(values) ); } export async function deletePointStandard(id: string): Promise { await sendWrite(`${POINT_PATH}/${encodeURIComponent(id)}`, 'DELETE'); }