From 2edde77baeab2a4ee9abdf43eb75a018cf1763a1 Mon Sep 17 00:00:00 2001 From: keyy1315 Date: Tue, 17 Jun 2025 14:45:31 +0900 Subject: [PATCH 1/8] refactor: add ErrorMessages Enum for handle api error --- src/app/api/qna/detail/route.ts | 6 +-- src/app/api/qna/file/route.ts | 47 +++-------------------- src/app/api/qna/list/route.ts | 11 +++--- src/app/api/qna/save/route.ts | 8 ++-- src/app/api/submission/admin-sub/route.ts | 14 +++++-- src/app/api/submission/admin/route.ts | 10 +++-- src/app/api/submission/builder/route.ts | 10 ++++- src/app/api/submission/super/route.ts | 40 ++++++------------- src/app/api/survey-sales/[id]/route.ts | 39 ++++++++----------- src/app/api/survey-sales/route.ts | 19 +++++---- src/utils/common-utils.js | 16 ++++++++ 11 files changed, 92 insertions(+), 128 deletions(-) diff --git a/src/app/api/qna/detail/route.ts b/src/app/api/qna/detail/route.ts index 1ac937c..d1f8078 100644 --- a/src/app/api/qna/detail/route.ts +++ b/src/app/api/qna/detail/route.ts @@ -1,7 +1,8 @@ -import { queryStringFormatter } from '@/utils/common-utils' +import { ERROR_MESSAGES, queryStringFormatter } from '@/utils/common-utils' import axios from 'axios' import { NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' +import { HttpStatusCode } from 'axios' async function getQnaDetail(request: Request): Promise { const { searchParams } = new URL(request.url) @@ -19,8 +20,7 @@ async function getQnaDetail(request: Request): Promise { } return NextResponse.json({ error: response.data.result }, { status: response.status }) } catch (error: any) { - console.error(error.response) - return NextResponse.json({ error: 'route error' }, { status: 500 }) + return NextResponse.json({ error: error.response.data.result ?? ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/qna/file/route.ts b/src/app/api/qna/file/route.ts index 49c7209..e050c3c 100644 --- a/src/app/api/qna/file/route.ts +++ b/src/app/api/qna/file/route.ts @@ -1,43 +1,7 @@ -import axios from 'axios' +import { HttpStatusCode } from 'axios' import { NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' - -// export async function GET(request: Request) { -// const { searchParams } = new URL(request.url) -// const encodeFileNo = searchParams.get('encodeFileNo') -// const srcFileNm = searchParams.get('srcFileNm') - -// if (!encodeFileNo) { -// return NextResponse.json({ error: 'encodeFileNo is required' }, { status: 400 }) -// } - -// try { -// const response = await axios.get(`${process.env.NEXT_PUBLIC_INQUIRY_API_URL}/api/file/downloadFile2`, { -// params: { -// encodeFileNo, -// }, -// responseType: 'arraybuffer', -// }) - -// if (response.headers['content-type'] === 'text/html;charset=utf-8') { -// return NextResponse.json({ error: 'file not found' }, { status: 404 }) -// } - -// const contentType = response.headers['content-type'] || 'application/octet-stream' -// const contentDisposition = response.headers['content-disposition'] || 'inline' - -// return new NextResponse(response.data, { -// status: 200, -// headers: { -// 'Content-Type': contentType, -// 'Content-Disposition': contentDisposition, -// }, -// }) -// } catch (error: any) { -// console.error('File download error:', error) -// return NextResponse.json({ error: error.response?.data || 'Failed to download file' }, { status: 500 }) -// } -// } +import { ERROR_MESSAGES } from '@/utils/common-utils' async function downloadFile(request: Request): Promise { const { searchParams } = new URL(request.url) @@ -45,7 +9,7 @@ async function downloadFile(request: Request): Promise { const srcFileNm = searchParams.get('srcFileNm') || 'downloaded-file' if (!encodeFileNo) { - return NextResponse.json({ error: 'encodeFileNo is required' }, { status: 400 }) + return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) } const url = `${process.env.NEXT_PUBLIC_INQUIRY_API_URL}/api/file/downloadFile2?encodeFileNo=${encodeFileNo}` @@ -54,7 +18,7 @@ async function downloadFile(request: Request): Promise { const resp = await fetch(url) if (!resp.ok) { - return NextResponse.json({ error: 'Failed to download file' }, { status: 500 }) + return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } const contentType = resp.headers.get('content-type') || 'application/octet-stream' @@ -68,8 +32,7 @@ async function downloadFile(request: Request): Promise { }, }) } catch (error: any) { - console.error('File download error:', error) - return NextResponse.json({ error: error.response?.data || 'Failed to download file' }, { status: 500 }) + return NextResponse.json({ error: error.response?.data ?? ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/qna/list/route.ts b/src/app/api/qna/list/route.ts index 12cf96e..7e8807d 100644 --- a/src/app/api/qna/list/route.ts +++ b/src/app/api/qna/list/route.ts @@ -1,6 +1,6 @@ -import axios from 'axios' +import axios, { HttpStatusCode } from 'axios' import { NextResponse } from 'next/server' -import { queryStringFormatter } from '@/utils/common-utils' +import { ERROR_MESSAGES, queryStringFormatter } from '@/utils/common-utils' import { getIronSession } from 'iron-session' import { cookies } from 'next/headers' import { loggerWrapper } from '@/libs/api-wrapper' @@ -12,7 +12,7 @@ async function getQnaList(request: Request): Promise { const session = await getIronSession(cookieStore, sessionOptions) if (!session.isLoggedIn) { - return NextResponse.json({ error: 'ログインしていません。' }, { status: 401 }) + return NextResponse.json({ error: ERROR_MESSAGES.UNAUTHORIZED }, { status: HttpStatusCode.Unauthorized }) } const { searchParams } = new URL(request.url) @@ -32,10 +32,9 @@ async function getQnaList(request: Request): Promise { if (response.status === 200) { return NextResponse.json(response.data) } - return NextResponse.json({ error: 'Failed to fetch qna list' }, { status: response.status }) + return NextResponse.json({ error: response.data.result }, { status: response.status }) } catch (error: any) { - console.error('Error fetching qna list:', error.response.data) - return NextResponse.json({ error: 'route error' }, { status: 500 }) + return NextResponse.json({ error: error.response.data.result ?? ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/qna/save/route.ts b/src/app/api/qna/save/route.ts index 8cd1ad5..429db83 100644 --- a/src/app/api/qna/save/route.ts +++ b/src/app/api/qna/save/route.ts @@ -1,6 +1,7 @@ -import axios from 'axios' +import axios, { HttpStatusCode } from 'axios' import { NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' +import { ERROR_MESSAGES } from '@/utils/common-utils' async function setQna(request: Request): Promise { const formData = await request.formData() @@ -14,10 +15,9 @@ async function setQna(request: Request): Promise { if (response.status === 200) { return NextResponse.json(response.data) } - return NextResponse.json({ error: response.data }, { status: response.status }) + return NextResponse.json({ error: response.data.result }, { status: response.status }) } catch (error: any) { - console.error('error:: ', error.response) - return NextResponse.json({ error: 'Failed to save qna' }, { status: 500 }) + return NextResponse.json({ error: error.response.data.result ?? ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/submission/admin-sub/route.ts b/src/app/api/submission/admin-sub/route.ts index 345a3ff..53812f8 100644 --- a/src/app/api/submission/admin-sub/route.ts +++ b/src/app/api/submission/admin-sub/route.ts @@ -2,12 +2,18 @@ import { prisma } from '@/libs/prisma' import { NextRequest, NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' import { SubmitTargetResponse } from '@/types/Survey' +import { ERROR_MESSAGES } from '@/utils/common-utils' +import { HttpStatusCode } from 'axios' // 2차점이 자신에게 매핑 된 1차 판매점과 관리자 정보 조회 async function getSubMissionAdminSub(request: NextRequest): Promise { try { const { searchParams } = new URL(request.url) - const id = searchParams.get('id') + const storeId = searchParams.get('storeId') + + if (!storeId) { + return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) + } const query = ` OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; @@ -23,7 +29,7 @@ async function getSubMissionAdminSub(request: NextRequest): Promise { try { const { searchParams } = new URL(request.url) - const id = searchParams.get('id') + const storeId = searchParams.get('storeId') const query = ` OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; @@ -32,7 +34,7 @@ async function getSubmissionAdmin(request: NextRequest): Promise { AND BCL.HEAD_CD = '103200' AND BCL.DEL_YN = 'N' WHERE MCSA.COMP_CD = '5200' - AND MCSA.STORE_ID = 'A03' + AND MCSA.STORE_ID = '${storeId}' AND MCSA.DEL_YN = 'N' ; CLOSE SYMMETRIC KEY SYMMETRICKEY; @@ -40,8 +42,8 @@ async function getSubmissionAdmin(request: NextRequest): Promise { const data: SuperPerson[] = await prisma.$queryRawUnsafe(query) return NextResponse.json(data) } catch (error) { - console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) - return NextResponse.json({ error: 'データの取得に失敗しました。' }, { status: 500 }) + console.error('❌ API ROUTE ERROR:', error) + return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/submission/builder/route.ts b/src/app/api/submission/builder/route.ts index cf43b08..6c831f6 100644 --- a/src/app/api/submission/builder/route.ts +++ b/src/app/api/submission/builder/route.ts @@ -2,6 +2,8 @@ import { prisma } from '@/libs/prisma' import { SubmitTargetResponse } from '@/types/Survey' import { NextRequest, NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' +import { ERROR_MESSAGES } from '@/utils/common-utils' +import { HttpStatusCode } from 'axios' // 2차점의 시공권한 user가 해당 판매점의 관리자 정보 조회 // N == 일반유저, S == 수퍼유저, B == 시공권한유저 @@ -10,6 +12,10 @@ async function getSubmissionBuilder(request: NextRequest): Promise const { searchParams } = new URL(request.url) const id = searchParams.get('id') + if (!id) { + return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) + } + const query = ` OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; SELECT @@ -33,8 +39,8 @@ async function getSubmissionBuilder(request: NextRequest): Promise const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) return NextResponse.json(data) } catch (error) { - console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error) - return NextResponse.json({ error: 'データの取得に失敗しました。' }, { status: 500 }) + console.error('❌ API ROUTE ERROR:', error) + return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/submission/super/route.ts b/src/app/api/submission/super/route.ts index c950dfe..76c5fc9 100644 --- a/src/app/api/submission/super/route.ts +++ b/src/app/api/submission/super/route.ts @@ -1,40 +1,22 @@ -import { prisma } from '@/libs/prisma' import { NextRequest, NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' - -type SuperPerson = { - storeId: string - userId: string - eMail: string -} +import { ERROR_MESSAGES } from '@/utils/common-utils' +import { HttpStatusCode } from 'axios' +import { SubmissionService } from '../service' async function getSubmissionSuper(request: NextRequest): Promise { try { const { searchParams } = new URL(request.url) - const id = searchParams.get('id') - - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCSA.STORE_ID - , BU.USER_ID - , CONVERT(NVARCHAR(100), DecryptByKey(BU.E_MAIL)) AS E_MAIL - FROM MS_CUST_STOREID_ADDITNL MCSA WITH(NOLOCK) - LEFT OUTER JOIN BC_USER bu WITH(NOLOCK) - ON MCSA.COMP_CD = BU.COMP_CD - AND MCSA.KAM_ID = BU.KAM_ID - AND BU.STAT_CD = 'A' - AND BU.DEL_YN = 'N' - WHERE MCSA.COMP_CD = '5200' - AND MCSA.STORE_ID = 'A03' - AND MCSA.DEL_YN = 'N'; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SuperPerson[] = await prisma.$queryRawUnsafe(query) + const storeId = searchParams.get('storeId') + if (!storeId) { + return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) + } + const submissionService = new SubmissionService(storeId, 'Super') + const data = await submissionService.getSubmissionTarget() return NextResponse.json(data) } catch (error) { - console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error); - return NextResponse.json({ error: 'データの取得に失敗しました。' }, { status: 500 }); + console.error('❌ API ROUTE ERROR:', error) + return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } diff --git a/src/app/api/survey-sales/[id]/route.ts b/src/app/api/survey-sales/[id]/route.ts index c1a76bc..a50c146 100644 --- a/src/app/api/survey-sales/[id]/route.ts +++ b/src/app/api/survey-sales/[id]/route.ts @@ -1,22 +1,13 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/libs/prisma' -import { convertToSnakeCase } from '@/utils/common-utils' +import { convertToSnakeCase, ERROR_MESSAGES } from '@/utils/common-utils' import { getIronSession } from 'iron-session' import { sessionOptions } from '@/libs/session' import { cookies } from 'next/headers' import type { SessionData } from '@/types/Auth' import { Prisma } from '@prisma/client' import { loggerWrapper } from '@/libs/api-wrapper' - -/** - * @description 조사 매물 조회 에러 메시지 - */ -const ERROR_MESSAGES = { - NOT_FOUND: 'データが見つかりません。', - UNAUTHORIZED: 'Unauthorized', - NO_PERMISSION: '該当物件の照会権限がありません。', - FETCH_ERROR: 'データの取得に失敗しました。', -} as const +import { HttpStatusCode } from 'axios' /** * @description T01 조회 권한 체크 @@ -139,7 +130,7 @@ async function getSurveySaleDetail(request: NextRequest): Promise const survey = await fetchSurvey(Number(id)) if (!survey) { - return NextResponse.json({ error: ERROR_MESSAGES.NOT_FOUND }, { status: 404 }) + return NextResponse.json({ error: ERROR_MESSAGES.NOT_FOUND }, { status: HttpStatusCode.NotFound }) } /** pdf 데이터 요청 여부, 권한 여부 확인 */ @@ -149,14 +140,14 @@ async function getSurveySaleDetail(request: NextRequest): Promise /** 로그인 여부 확인 */ if (!session?.isLoggedIn || session?.role === null) { - return NextResponse.json({ error: ERROR_MESSAGES.UNAUTHORIZED }, { status: 401 }) + return NextResponse.json({ error: ERROR_MESSAGES.UNAUTHORIZED }, { status: HttpStatusCode.Unauthorized }) } /** 권한 없음 */ - return NextResponse.json({ error: ERROR_MESSAGES.NO_PERMISSION }, { status: 403 }) + return NextResponse.json({ error: ERROR_MESSAGES.NO_PERMISSION }, { status: HttpStatusCode.Forbidden }) } catch (error) { - console.error('Error fetching survey:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: 500 }) + console.error('❌ API ROUTE ERROR:', error) + return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) } } @@ -254,8 +245,8 @@ async function updateSurveySaleDetail(request: NextRequest): Promise { return obj; } + +/** + * @description 조사 매물 조회 에러 메시지 + */ +export const ERROR_MESSAGES = { + /** 데이터를 찾을 수 없습니다. */ + NOT_FOUND: 'データが見つかりません。', + /** 승인되지 않았습니다. */ + UNAUTHORIZED: '承認されていません。', + /** 권한이 없습니다. */ + NO_PERMISSION: '権限がありません。', + /** 데이터의 조회에 실패했습니다. */ + FETCH_ERROR: 'データの取得に失敗しました。', + /** 잘못된 요청입니다. */ + BAD_REQUEST: '間違ったリクエストです。', +} From f6729069d7a3b945ccda7f2b1efc44568e1e84e5 Mon Sep 17 00:00:00 2001 From: keyy1315 Date: Tue, 17 Jun 2025 14:54:42 +0900 Subject: [PATCH 2/8] refactor: add service layer to centralize business logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드 가독성 및 유지보수성 향상을 위한 서비스 레이어 도입 --- src/app/api/submission/admin-sub/route.ts | 45 ------- src/app/api/submission/admin/route.ts | 50 -------- src/app/api/submission/builder/route.ts | 47 -------- src/app/api/submission/{super => }/route.ts | 17 +-- src/app/api/submission/service.ts | 126 ++++++++++++++++++++ src/hooks/useSurvey.ts | 11 +- 6 files changed, 139 insertions(+), 157 deletions(-) delete mode 100644 src/app/api/submission/admin-sub/route.ts delete mode 100644 src/app/api/submission/admin/route.ts delete mode 100644 src/app/api/submission/builder/route.ts rename src/app/api/submission/{super => }/route.ts (68%) create mode 100644 src/app/api/submission/service.ts diff --git a/src/app/api/submission/admin-sub/route.ts b/src/app/api/submission/admin-sub/route.ts deleted file mode 100644 index 53812f8..0000000 --- a/src/app/api/submission/admin-sub/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { prisma } from '@/libs/prisma' -import { NextRequest, NextResponse } from 'next/server' -import { loggerWrapper } from '@/libs/api-wrapper' -import { SubmitTargetResponse } from '@/types/Survey' -import { ERROR_MESSAGES } from '@/utils/common-utils' -import { HttpStatusCode } from 'axios' - -// 2차점이 자신에게 매핑 된 1차 판매점과 관리자 정보 조회 -async function getSubMissionAdminSub(request: NextRequest): Promise { - try { - const { searchParams } = new URL(request.url) - const storeId = searchParams.get('storeId') - - if (!storeId) { - return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) - } - - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCS.STORE_ID AS targetStoreId - , MCS.STORE_QCAST_NM AS targetStoreNm - , MCP.EOS_LOGIN_ID AS repUserId - , CONVERT(NVARCHAR(100), DecryptByKey(MCP.EMAIL)) AS repUserEmail - , MCP.AUTHORITY AS auth - FROM MS_CUST_STOREID MCS WITH(NOLOCK) - LEFT OUTER JOIN MS_CUST_PERSON MCP WITH(NOLOCK) - ON MCS.COMP_CD = MCP.COMP_CD - AND MCS.STORE_ID = MCP.STORE_ID - AND MCP.DEL_YN = 'N' - WHERE MCS.COMP_CD = '5200' - AND MCS.STORE_ID = (SELECT STORE_ID FROM MS_CUST_AGENCY_STOREID WHERE AGENCY_STORE_ID = '${storeId}' AND DEL_YN = 'N') - AND MCP.EMAIL IS NOT NULL - AND MCS.DEL_YN = 'N'; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) - return NextResponse.json(data) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) - } -} - -export const GET = loggerWrapper(getSubMissionAdminSub) diff --git a/src/app/api/submission/admin/route.ts b/src/app/api/submission/admin/route.ts deleted file mode 100644 index 0318e1a..0000000 --- a/src/app/api/submission/admin/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { prisma } from '@/libs/prisma' -import { NextRequest, NextResponse } from 'next/server' -import { loggerWrapper } from '@/libs/api-wrapper' -import { ERROR_MESSAGES } from '@/utils/common-utils' -import { HttpStatusCode } from 'axios' - -type SuperPerson = { - storeId: string - salesOfficeCd: string - fromEmail: string - toEmail: string -} - -async function getSubmissionAdmin(request: NextRequest): Promise { - try { - const { searchParams } = new URL(request.url) - const storeId = searchParams.get('storeId') - - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCSA.STORE_ID - , BCL.CODE AS SALES_OFFICE_CD - , REF_CHR1 AS FROM_E_MAIL - , REF_CHR2 AS TO_E_MAIL - FROM MS_CUST_STOREID_ADDITNL MCSA WITH(NOLOCK) - LEFT OUTER JOIN IF_PERSON_OFFICE_MAPPING IPOM WITH(NOLOCK) - ON MCSA.KAM_ID = IPOM.LIFNR - AND IF_STS = 'R' - AND VKBUR IS NOT NULL - AND VKBUR != '' - INNER JOIN BC_COMM_L BCL WITH(NOLOCK) - ON BCL.CODE = IPOM.VKBUR - AND BCL.HEAD_CD = '103200' - AND BCL.DEL_YN = 'N' - WHERE MCSA.COMP_CD = '5200' - AND MCSA.STORE_ID = '${storeId}' - AND MCSA.DEL_YN = 'N' - ; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SuperPerson[] = await prisma.$queryRawUnsafe(query) - return NextResponse.json(data) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) - } -} - -export const GET = loggerWrapper(getSubmissionAdmin) diff --git a/src/app/api/submission/builder/route.ts b/src/app/api/submission/builder/route.ts deleted file mode 100644 index 6c831f6..0000000 --- a/src/app/api/submission/builder/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { prisma } from '@/libs/prisma' -import { SubmitTargetResponse } from '@/types/Survey' -import { NextRequest, NextResponse } from 'next/server' -import { loggerWrapper } from '@/libs/api-wrapper' -import { ERROR_MESSAGES } from '@/utils/common-utils' -import { HttpStatusCode } from 'axios' - -// 2차점의 시공권한 user가 해당 판매점의 관리자 정보 조회 -// N == 일반유저, S == 수퍼유저, B == 시공권한유저 -async function getSubmissionBuilder(request: NextRequest): Promise { - try { - const { searchParams } = new URL(request.url) - const id = searchParams.get('id') - - if (!id) { - return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) - } - - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCAS.AGENCY_STORE_ID AS targetStoreId - , MCAS.AGENCY_QCAST_NM AS targetStoreNm - , BQU.USER_ID AS repUserId - , CONVERT(NVARCHAR(100), DecryptByKey(BQU.EMAIL)) AS repUserEmail - , BQU.USER_AUTH_CD AS auth - FROM MS_CUST_AGENCY_STOREID MCAS WITH(NOLOCK) - LEFT OUTER JOIN BC_QM_USER BQU WITH(NOLOCK) - ON MCAS.COMP_CD = BQU.COMP_CD - AND MCAS.AGENCY_STORE_ID = BQU.AGENCY_STORE_ID - AND MCAS.DEL_YN = 'N' - WHERE MCAS.COMP_CD = '5200' - AND MCAS.AGENCY_STORE_ID = '${id}' - AND BQU.EMAIL IS NOT NULL - AND BQU.USER_AUTH_CD != 'B' - AND MCAS.DEL_YN = 'N'; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) - return NextResponse.json(data) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) - } -} - -export const GET = loggerWrapper(getSubmissionBuilder) diff --git a/src/app/api/submission/super/route.ts b/src/app/api/submission/route.ts similarity index 68% rename from src/app/api/submission/super/route.ts rename to src/app/api/submission/route.ts index 76c5fc9..bc4fc4a 100644 --- a/src/app/api/submission/super/route.ts +++ b/src/app/api/submission/route.ts @@ -1,17 +1,20 @@ import { NextRequest, NextResponse } from 'next/server' -import { loggerWrapper } from '@/libs/api-wrapper' -import { ERROR_MESSAGES } from '@/utils/common-utils' +import { SubmissionService } from './service' import { HttpStatusCode } from 'axios' -import { SubmissionService } from '../service' +import { ERROR_MESSAGES } from '@/utils/common-utils' +import { loggerWrapper } from '@/libs/api-wrapper' -async function getSubmissionSuper(request: NextRequest): Promise { +async function getSubmitTargetData(request: NextRequest): Promise { try { const { searchParams } = new URL(request.url) const storeId = searchParams.get('storeId') - if (!storeId) { + const role = searchParams.get('role') + + if (!storeId || !role) { return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) } - const submissionService = new SubmissionService(storeId, 'Super') + + const submissionService = new SubmissionService(storeId, role) const data = await submissionService.getSubmissionTarget() return NextResponse.json(data) } catch (error) { @@ -20,4 +23,4 @@ async function getSubmissionSuper(request: NextRequest): Promise { } } -export const GET = loggerWrapper(getSubmissionSuper) +export const GET = loggerWrapper(getSubmitTargetData) diff --git a/src/app/api/submission/service.ts b/src/app/api/submission/service.ts new file mode 100644 index 0000000..4431553 --- /dev/null +++ b/src/app/api/submission/service.ts @@ -0,0 +1,126 @@ +import { prisma } from '@/libs/prisma' +import { SubmitTargetResponse } from '@/types/Survey' + +export class SubmissionService { + private storeId: string + private role: string + + constructor(storeId: string, role: string) { + this.storeId = storeId + this.role = role + } + + async getSubmissionTarget(): Promise { + switch (this.role) { + case 'Admin': + return this.getSubmissionTargetAdmin() + case 'Admin_Sub': + return this.getSubmissionTargetAdminSub() + case 'Builder': + return this.getSubmissionTargetBuilder() + case 'Super': + return this.getSubmissionTargetSuper() + default: + return null + } + } + + private async getSubmissionTargetAdmin(): Promise { + const query = ` + OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; + SELECT + MCSA.STORE_ID AS targetStoreId + , BCL.CODE AS salesOfficeCd + , REF_CHR1 AS fromEmail + , REF_CHR2 AS toEmail + FROM MS_CUST_STOREID_ADDITNL MCSA WITH(NOLOCK) + LEFT OUTER JOIN IF_PERSON_OFFICE_MAPPING IPOM WITH(NOLOCK) + ON MCSA.KAM_ID = IPOM.LIFNR + AND IF_STS = 'R' + AND VKBUR IS NOT NULL + AND VKBUR != '' + INNER JOIN BC_COMM_L BCL WITH(NOLOCK) + ON BCL.CODE = IPOM.VKBUR + AND BCL.HEAD_CD = '103200' + AND BCL.DEL_YN = 'N' + WHERE MCSA.COMP_CD = '5200' + AND MCSA.STORE_ID = '${this.storeId}' + AND MCSA.DEL_YN = 'N'; + CLOSE SYMMETRIC KEY SYMMETRICKEY; + ` + const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) + return data + } + + + private async getSubmissionTargetAdminSub(): Promise { + const query = ` + OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; + SELECT + MCS.STORE_ID AS targetStoreId + , MCS.STORE_QCAST_NM AS targetStoreNm + , MCP.EOS_LOGIN_ID AS repUserId + , CONVERT(NVARCHAR(100), DecryptByKey(MCP.EMAIL)) AS repUserEmail + , MCP.AUTHORITY AS auth + FROM MS_CUST_STOREID MCS WITH(NOLOCK) + LEFT OUTER JOIN MS_CUST_PERSON MCP WITH(NOLOCK) + ON MCS.COMP_CD = MCP.COMP_CD + AND MCS.STORE_ID = MCP.STORE_ID + AND MCP.DEL_YN = 'N' + WHERE MCS.COMP_CD = '5200' + AND MCS.STORE_ID = (SELECT STORE_ID FROM MS_CUST_AGENCY_STOREID WHERE AGENCY_STORE_ID = '${this.storeId}' AND DEL_YN = 'N') + AND MCP.EMAIL IS NOT NULL + AND MCS.DEL_YN = 'N'; + CLOSE SYMMETRIC KEY SYMMETRICKEY; + ` + const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) + return data + } + + private async getSubmissionTargetBuilder(): Promise { + const query = ` + OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; + SELECT + MCAS.AGENCY_STORE_ID AS targetStoreId + , MCAS.AGENCY_QCAST_NM AS targetStoreNm + , BQU.USER_ID AS repUserId + , CONVERT(NVARCHAR(100), DecryptByKey(BQU.EMAIL)) AS repUserEmail + , BQU.USER_AUTH_CD AS auth + FROM MS_CUST_AGENCY_STOREID MCAS WITH(NOLOCK) + LEFT OUTER JOIN BC_QM_USER BQU WITH(NOLOCK) + ON MCAS.COMP_CD = BQU.COMP_CD + AND MCAS.AGENCY_STORE_ID = BQU.AGENCY_STORE_ID + AND MCAS.DEL_YN = 'N' + WHERE MCAS.COMP_CD = '5200' + AND MCAS.AGENCY_STORE_ID = '${this.storeId}' + AND BQU.EMAIL IS NOT NULL + AND BQU.USER_AUTH_CD != 'B' + AND MCAS.DEL_YN = 'N'; + CLOSE SYMMETRIC KEY SYMMETRICKEY; + ` + const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) + return data + } + + private async getSubmissionTargetSuper(): Promise { + const query = ` + OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; + SELECT + MCSA.STORE_ID AS targetStoreId + , BU.USER_ID AS repUserId + , CONVERT(NVARCHAR(100), DecryptByKey(BU.E_MAIL)) AS repUserEmail + FROM MS_CUST_STOREID_ADDITNL MCSA WITH(NOLOCK) + LEFT OUTER JOIN BC_USER bu WITH(NOLOCK) + ON MCSA.COMP_CD = BU.COMP_CD + AND MCSA.KAM_ID = BU.KAM_ID + AND BU.STAT_CD = 'A' + AND BU.DEL_YN = 'N' + WHERE MCSA.COMP_CD = '5200' + AND MCSA.STORE_ID = '${this.storeId}' + AND MCSA.DEL_YN = 'N'; + CLOSE SYMMETRIC KEY SYMMETRICKEY; + ` + const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) + return data + } +} diff --git a/src/hooks/useSurvey.ts b/src/hooks/useSurvey.ts index bee304a..d6581f9 100644 --- a/src/hooks/useSurvey.ts +++ b/src/hooks/useSurvey.ts @@ -393,21 +393,16 @@ export function useSurvey( const getSubmitTarget = async (params: { storeId: string; role: string }): Promise => { try { if (!params.storeId) { + /** 판매점 ID 없는 경우 */ alert('販売店IDがありません。') return null } - - const endpoints = { - Admin_Sub: `/api/submission/admin-sub?id=${params.storeId}`, - Builder: `/api/submission/builder?id=${params.storeId}`, - } as const - - const endpoint = endpoints[params.role as keyof typeof endpoints] + const endpoint = `/api/submission?storeId=${params.storeId}&role=${params.role}` if (!endpoint) { + /** 권한 오류 */ alert('権限が間違っています。') return null } - const { data } = await axiosInstance(null).get(endpoint) return data } catch (error: any) { From c92c1c247007d67162510e9b95c174ad54d92403 Mon Sep 17 00:00:00 2001 From: Daseul Kim Date: Tue, 17 Jun 2025 17:32:16 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20logger=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80,=20prod=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=83=9D=EC=84=B1=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libs/logger.ts | 67 +++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/src/libs/logger.ts b/src/libs/logger.ts index ab73450..480d408 100644 --- a/src/libs/logger.ts +++ b/src/libs/logger.ts @@ -15,9 +15,14 @@ interface ApiLogData { body: string | undefined } +/* 현재 날짜 반환 함수 (YYYY-MM-DD 형식) */ +const getCurrentDate = (): string => { + return new Date().toISOString().split('T')[0] +} + /* 날짜별 로그 파일 경로 생성 함수 */ const getLogFilePath = (): string => { - const today = new Date().toISOString().split('T')[0] // YYYY-MM-DD 형식 + const today: string = getCurrentDate() return join(process.cwd(), 'logs', `onsite-survey-${today}.log`) } @@ -25,15 +30,10 @@ const getLogFilePath = (): string => { class DailyLogger { private currentDate: string private logger: pino.Logger - private destination: ReturnType + private destination?: ReturnType constructor() { - this.currentDate = new Date().toISOString().split('T')[0] - this.destination = pino.destination({ - dest: getLogFilePath(), - mkdir: true, - sync: false, - }) + this.currentDate = getCurrentDate() this.logger = this.createLogger() /* kill signal 핸들러 등록 */ @@ -42,14 +42,31 @@ class DailyLogger { } private async handleShutdown(): Promise { - this.destination.flushSync() - this.destination.end() + if (isProduction && this.destination) { + this.destination.flushSync() + this.destination.end() + } + this.logger.flush() } private createLogger(): pino.Logger { + if (!isProduction) return pino({ level: 'silent' }) + + /* 기존 destination 종료 */ + if (this.destination) { + this.destination.flushSync() + this.destination.end() + } + /* 새로운 destination 생성 */ + this.destination = pino.destination({ + dest: getLogFilePath(), + mkdir: true, + sync: false, + }) + return pino( { - level: isProduction ? 'info' : 'silent', + level: 'info', timestamp: pino.stdTimeFunctions.isoTime, }, this.destination, @@ -57,24 +74,16 @@ class DailyLogger { } public info(obj: any, msg?: string): void { - const today = new Date().toISOString().split('T')[0] - - if (today !== this.currentDate) { - /* 기존 destination 종료 */ - this.destination.flushSync() - this.destination.end() - - /* 새로운 destination 생성 */ - this.destination = pino.destination({ - dest: getLogFilePath(), - mkdir: true, - sync: false, - }) - this.currentDate = today - this.logger = this.createLogger() + try { + const today: string = getCurrentDate() + if (today !== this.currentDate) { + this.currentDate = today + this.logger = this.createLogger() + } + this.logger.info(obj, msg) + } catch (error) { + console.error(`[DailyLogger] Failed to write log: ${error}`) } - - this.logger.info(obj, msg) } } @@ -83,6 +92,8 @@ const dailyLogger = new DailyLogger() /* API 로그 기록 함수 */ export const writeApiLog = async (request: NextRequest, responseStatus: number): Promise => { + if (!isProduction) return + const logData: ApiLogData = { responseStatus: responseStatus, method: request.method, From 6c1bd8775c835a3a051cc11c1ec7dc221b0f6c53 Mon Sep 17 00:00:00 2001 From: keyy1315 Date: Tue, 17 Jun 2025 18:21:02 +0900 Subject: [PATCH 4/8] refactor: implement SurveySalesService for survey sales management --- src/app/api/qna/save/route.ts | 1 - src/app/api/submission/route.ts | 36 +++ src/app/api/submission/service.ts | 77 +---- src/app/api/survey-sales/[id]/route.ts | 248 +++------------- src/app/api/survey-sales/route.ts | 334 ++------------------- src/app/api/survey-sales/service.ts | 388 +++++++++++++++++++++++++ src/hooks/useSurvey.ts | 35 ++- src/types/Survey.ts | 22 ++ 8 files changed, 541 insertions(+), 600 deletions(-) create mode 100644 src/app/api/survey-sales/service.ts diff --git a/src/app/api/qna/save/route.ts b/src/app/api/qna/save/route.ts index 429db83..4575479 100644 --- a/src/app/api/qna/save/route.ts +++ b/src/app/api/qna/save/route.ts @@ -5,7 +5,6 @@ import { ERROR_MESSAGES } from '@/utils/common-utils' async function setQna(request: Request): Promise { const formData = await request.formData() - console.log(formData) try { const response = await axios.post(`${process.env.NEXT_PUBLIC_INQUIRY_API_URL}/api/qna/save`, formData, { headers: { diff --git a/src/app/api/submission/route.ts b/src/app/api/submission/route.ts index bc4fc4a..e0b62ee 100644 --- a/src/app/api/submission/route.ts +++ b/src/app/api/submission/route.ts @@ -4,6 +4,42 @@ import { HttpStatusCode } from 'axios' import { ERROR_MESSAGES } from '@/utils/common-utils' import { loggerWrapper } from '@/libs/api-wrapper' +/** + * @api {GET} /api/submission 제출 대상 조회 + * @apiName GET /api/submission + * @apiGroup Submission + * @apiDescription 제출 대상 조회 + * + * @param {String} storeId 판매점 ID (required) + * @param {String} role 권한 (required) + * + * @apiSuccess {Object} data 제출 대상 목록 + * @apiSuccess {String} data.targetStoreId 판매점 ID + * @apiSuccess {String} data.salesOfficeCd 영업소 코드 + * @apiSuccess {String} data.fromEmail 발신자 이메일 + * @apiSuccess {String} data.toEmail 수신자 이메일 + * + * @returns {Promise} 제출 대상 목록 + * @apiSuccessExample {json} Success-Response: + * { + * "data": [ + * { + * "targetStoreId": "1234567890", + * "salesOfficeCd": "1234567890", + * "fromEmail": "1234567890", + * "toEmail": "1234567890" + * } + * ] + * } + * + * @apiExample {curl} Example usage: + * curl -X GET \ + * -H "Content-Type: application/json" \ + * http://localhost:3000/api/submission?storeId=1234567890&role=admin + * + * @apiError {Object} error 에러 객체 + * @apiError {String} error.message 에러 메시지 + */ async function getSubmitTargetData(request: NextRequest): Promise { try { const { searchParams } = new URL(request.url) diff --git a/src/app/api/submission/service.ts b/src/app/api/submission/service.ts index 4431553..7f7c86e 100644 --- a/src/app/api/submission/service.ts +++ b/src/app/api/submission/service.ts @@ -4,6 +4,11 @@ import { SubmitTargetResponse } from '@/types/Survey' export class SubmissionService { private storeId: string private role: string + private readonly BASE_QUERY = ` + DECLARE @storeId NVARCHAR(50); + SET @storeId = @p1; + OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; + ` constructor(storeId: string, role: string) { this.storeId = storeId @@ -12,51 +17,18 @@ export class SubmissionService { async getSubmissionTarget(): Promise { switch (this.role) { - case 'Admin': - return this.getSubmissionTargetAdmin() case 'Admin_Sub': return this.getSubmissionTargetAdminSub() case 'Builder': return this.getSubmissionTargetBuilder() - case 'Super': - return this.getSubmissionTargetSuper() default: return null } } - private async getSubmissionTargetAdmin(): Promise { - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCSA.STORE_ID AS targetStoreId - , BCL.CODE AS salesOfficeCd - , REF_CHR1 AS fromEmail - , REF_CHR2 AS toEmail - FROM MS_CUST_STOREID_ADDITNL MCSA WITH(NOLOCK) - LEFT OUTER JOIN IF_PERSON_OFFICE_MAPPING IPOM WITH(NOLOCK) - ON MCSA.KAM_ID = IPOM.LIFNR - AND IF_STS = 'R' - AND VKBUR IS NOT NULL - AND VKBUR != '' - INNER JOIN BC_COMM_L BCL WITH(NOLOCK) - ON BCL.CODE = IPOM.VKBUR - AND BCL.HEAD_CD = '103200' - AND BCL.DEL_YN = 'N' - WHERE MCSA.COMP_CD = '5200' - AND MCSA.STORE_ID = '${this.storeId}' - AND MCSA.DEL_YN = 'N'; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) - return data - } - - private async getSubmissionTargetAdminSub(): Promise { const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT + SELECT MCS.STORE_ID AS targetStoreId , MCS.STORE_QCAST_NM AS targetStoreNm , MCP.EOS_LOGIN_ID AS repUserId @@ -68,18 +40,16 @@ export class SubmissionService { AND MCS.STORE_ID = MCP.STORE_ID AND MCP.DEL_YN = 'N' WHERE MCS.COMP_CD = '5200' - AND MCS.STORE_ID = (SELECT STORE_ID FROM MS_CUST_AGENCY_STOREID WHERE AGENCY_STORE_ID = '${this.storeId}' AND DEL_YN = 'N') - AND MCP.EMAIL IS NOT NULL + AND MCS.STORE_ID IN (SELECT STORE_ID FROM MS_CUST_AGENCY_STOREID WHERE AGENCY_STORE_ID = @storeId AND DEL_YN = 'N') + AND MCP.EMAIL IS NOT NULL AND MCS.DEL_YN = 'N'; CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) - return data + ` + return await prisma.$queryRawUnsafe(this.BASE_QUERY + query, this.storeId) } private async getSubmissionTargetBuilder(): Promise { const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; SELECT MCAS.AGENCY_STORE_ID AS targetStoreId , MCAS.AGENCY_QCAST_NM AS targetStoreNm @@ -92,35 +62,12 @@ export class SubmissionService { AND MCAS.AGENCY_STORE_ID = BQU.AGENCY_STORE_ID AND MCAS.DEL_YN = 'N' WHERE MCAS.COMP_CD = '5200' - AND MCAS.AGENCY_STORE_ID = '${this.storeId}' + AND MCAS.AGENCY_STORE_ID = @storeId AND BQU.EMAIL IS NOT NULL AND BQU.USER_AUTH_CD != 'B' AND MCAS.DEL_YN = 'N'; CLOSE SYMMETRIC KEY SYMMETRICKEY; ` - const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) - return data - } - - private async getSubmissionTargetSuper(): Promise { - const query = ` - OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP; - SELECT - MCSA.STORE_ID AS targetStoreId - , BU.USER_ID AS repUserId - , CONVERT(NVARCHAR(100), DecryptByKey(BU.E_MAIL)) AS repUserEmail - FROM MS_CUST_STOREID_ADDITNL MCSA WITH(NOLOCK) - LEFT OUTER JOIN BC_USER bu WITH(NOLOCK) - ON MCSA.COMP_CD = BU.COMP_CD - AND MCSA.KAM_ID = BU.KAM_ID - AND BU.STAT_CD = 'A' - AND BU.DEL_YN = 'N' - WHERE MCSA.COMP_CD = '5200' - AND MCSA.STORE_ID = '${this.storeId}' - AND MCSA.DEL_YN = 'N'; - CLOSE SYMMETRIC KEY SYMMETRICKEY; - ` - const data: SubmitTargetResponse[] = await prisma.$queryRawUnsafe(query) - return data + return await prisma.$queryRawUnsafe(this.BASE_QUERY + query, this.storeId) } } diff --git a/src/app/api/survey-sales/[id]/route.ts b/src/app/api/survey-sales/[id]/route.ts index a50c146..29e1635 100644 --- a/src/app/api/survey-sales/[id]/route.ts +++ b/src/app/api/survey-sales/[id]/route.ts @@ -1,88 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/libs/prisma' -import { convertToSnakeCase, ERROR_MESSAGES } from '@/utils/common-utils' import { getIronSession } from 'iron-session' import { sessionOptions } from '@/libs/session' import { cookies } from 'next/headers' import type { SessionData } from '@/types/Auth' -import { Prisma } from '@prisma/client' -import { loggerWrapper } from '@/libs/api-wrapper' import { HttpStatusCode } from 'axios' - -/** - * @description T01 조회 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @returns {boolean} 조사 매물 임시 저장 여부 - */ -const checkT01Role = (survey: any): boolean => survey.SRL_NO !== '一時保存' - -/** - * @description Admin (1차 판매점) 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @param {string | null} storeId 판매점 ID - * @returns {boolean} 권한 존재 여부 - */ -const checkAdminRole = (survey: any, storeId: string | null): boolean => { - if (!storeId) return false - return survey.SUBMISSION_STATUS ? survey.SUBMISSION_TARGET_ID === storeId || survey.STORE_ID === storeId : survey.STORE_ID === storeId -} - -/** - * @description Admin_Sub (2차 판매점) 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @param {string | null} storeId 판매점 ID - * @returns {boolean} 권한 존재 여부 - */ -const checkAdminSubRole = (survey: any, storeId: string | null): boolean => { - if (!storeId) return false - return survey.SUBMISSION_STATUS - ? survey.SUBMISSION_TARGET_ID === storeId || (survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID) - : survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID -} - -/** - * @description Partner (파트너) 또는 Builder (2차 판매점의 시공권한 회원) 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @param {string | null} builderId 시공점 ID - * @returns {boolean} 권한 존재 여부 - */ -const checkPartnerOrBuilderRole = (survey: any, builderId: string | null): boolean => { - if (!builderId) return false - return survey.CONSTRUCTION_POINT_ID === builderId -} - -/** - * @description 권한 체크 - * @param {any} survey 조사 매물 데이터 - * @param {any} session 세션 데이터 - * @returns {boolean} 권한 존재 여부 - */ -const checkRole = (survey: any, session: any): boolean => { - if (!survey || !session.isLoggedIn) return false - - const roleChecks = { - T01: () => checkT01Role(survey), - Admin: () => checkAdminRole(survey, session.storeId), - Admin_Sub: () => checkAdminSubRole(survey, session.storeId), - Partner: () => checkPartnerOrBuilderRole(survey, session.builderId), - Builder: () => checkPartnerOrBuilderRole(survey, session.builderId), - } - - return roleChecks[session.role as keyof typeof roleChecks]?.() ?? false -} - -/** - * @description 조사 매물 조회 - * @param {number} id 조사 매물 ID - * @returns {Promise} 조사 매물 데이터 - */ -const fetchSurvey = async (id: number) => { - // @ts-ignore - return await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ - where: { ID: id }, - include: { DETAIL_INFO: true }, - }) -} +import { ERROR_MESSAGES } from '@/utils/common-utils' +import { loggerWrapper } from '@/libs/api-wrapper' +import { SurveySalesService } from '../service' /** * @api {GET} /api/survey-sales/:id 조사 매물 조회 API @@ -121,70 +45,28 @@ const fetchSurvey = async (id: number) => { * } */ async function getSurveySaleDetail(request: NextRequest): Promise { - try { - const cookieStore = await cookies() - const session = await getIronSession(cookieStore, sessionOptions) - const id = request.nextUrl.pathname.split('/').pop() ?? '' - const { searchParams } = new URL(request.url) - const isPdf = searchParams.get('isPdf') === 'true' + const cookieStore = await cookies() + const session = await getIronSession(cookieStore, sessionOptions) + const id = request.nextUrl.pathname.split('/').pop() ?? '' + const { searchParams } = new URL(request.url) + const isPdf = searchParams.get('isPdf') === 'true' - const survey = await fetchSurvey(Number(id)) - if (!survey) { - return NextResponse.json({ error: ERROR_MESSAGES.NOT_FOUND }, { status: HttpStatusCode.NotFound }) - } + const service = new SurveySalesService({}) + const survey = await service.tryFunction(() => service.fetchSurvey(Number(id))) - /** pdf 데이터 요청 여부, 권한 여부 확인 */ - if (isPdf || checkRole(survey, session)) { - return NextResponse.json(survey) - } - - /** 로그인 여부 확인 */ - if (!session?.isLoggedIn || session?.role === null) { - return NextResponse.json({ error: ERROR_MESSAGES.UNAUTHORIZED }, { status: HttpStatusCode.Unauthorized }) - } - - /** 권한 없음 */ - return NextResponse.json({ error: ERROR_MESSAGES.NO_PERMISSION }, { status: HttpStatusCode.Forbidden }) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + if (!survey) { + return NextResponse.json({ error: ERROR_MESSAGES.NOT_FOUND }, { status: HttpStatusCode.NotFound }) } -} -/** - * @description 새로운 SRL_NO 생성 - * @param {string} srlNo 기존 SRL_NO - * @param {string} storeId 판매점 ID - * @param {string} role 권한 - * @returns {Promise} 새로운 SRL_NO - */ -const getNewSrlNo = async (srlNo: string, storeId: string, role: string) => { - const srlRole = role === 'T01' || role === 'Admin' ? 'HO' : role === 'Admin_Sub' || role === 'Builder' ? 'HM' : '' - - let newSrlNo = srlNo - if (srlNo.startsWith('一時保存')) { - //@ts-ignore - const lastSurvey = await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ - where: { - SRL_NO: { - startsWith: srlRole, - }, - }, - orderBy: { - ID: 'desc', - }, - }) - const lastNo = lastSurvey ? parseInt(lastSurvey.SRL_NO.slice(-3)) : 0 - - newSrlNo = - srlRole + - storeId + - new Date().getFullYear().toString().slice(-2) + - (new Date().getMonth() + 1).toString().padStart(2, '0') + - new Date().getDate().toString().padStart(2, '0') + - (lastNo + 1).toString().padStart(3, '0') + if (isPdf || service.checkRole(survey, session)) { + return NextResponse.json(survey) } - return newSrlNo + + if (!session?.isLoggedIn || session?.role === null) { + return NextResponse.json({ error: ERROR_MESSAGES.UNAUTHORIZED }, { status: HttpStatusCode.Unauthorized }) + } + + return NextResponse.json({ error: ERROR_MESSAGES.NO_PERMISSION }, { status: HttpStatusCode.Forbidden }) } /** @@ -221,33 +103,12 @@ const getNewSrlNo = async (srlNo: string, storeId: string, role: string) => { * } * */ async function updateSurveySaleDetail(request: NextRequest): Promise { - try { - const id = request.nextUrl.pathname.split('/').pop() ?? '' - const body = await request.json() - const { detailInfo, ...basicInfo } = body.survey + const id = request.nextUrl.pathname.split('/').pop() ?? '' + const body = await request.json() + const service = new SurveySalesService({}) - // PUT 요청 시 임시저장 여부 확인 후 임시저장 시 기존 SRL_NO 사용, 기본 저장 시 새로운 SRL_NO 생성 - const newSrlNo = body.isTemporary ? body.survey.srlNo : await getNewSrlNo(body.survey.srlNo, body.storeId, body.role) - // @ts-ignore - const survey = await prisma.SD_SURVEY_SALES_BASIC_INFO.update({ - where: { ID: Number(id) }, - data: { - ...convertToSnakeCase(basicInfo), - SRL_NO: newSrlNo, - UPT_DT: new Date(), - DETAIL_INFO: { - update: convertToSnakeCase(detailInfo), - }, - }, - include: { - DETAIL_INFO: true, - }, - }) - return NextResponse.json(survey) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) - } + const survey = await service.tryFunction(() => service.updateSurvey(Number(id), body.survey, body.isTemporary, body.storeId, body.role)) + return NextResponse.json(survey) } /** @@ -269,36 +130,10 @@ async function updateSurveySaleDetail(request: NextRequest): Promise { - try { - const id = request.nextUrl.pathname.split('/').pop() ?? '' - - await prisma.$transaction(async (tx: Prisma.TransactionClient) => { - // @ts-ignore - const detailData = await tx.SD_SURVEY_SALES_BASIC_INFO.findUnique({ - where: { ID: Number(id) }, - select: { - DETAIL_INFO: true, - }, - }) - - if (detailData?.DETAIL_INFO?.ID) { - // @ts-ignore - await tx.SD_SURVEY_SALES_DETAIL_INFO.delete({ - where: { ID: Number(detailData.DETAIL_INFO.ID) }, - }) - } - - // @ts-ignore - await tx.SD_SURVEY_SALES_BASIC_INFO.delete({ - where: { ID: Number(id) }, - }) - }) - - return NextResponse.json({ status: HttpStatusCode.Ok }) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) - } + const id = request.nextUrl.pathname.split('/').pop() ?? '' + const service = new SurveySalesService({}) + await service.tryFunction(() => service.deleteSurvey(Number(id))) + return NextResponse.json({ status: HttpStatusCode.Ok }) } /** @@ -337,25 +172,12 @@ async function deleteSurveySaleDetail(request: NextRequest): Promise { - try { - const id = request.nextUrl.pathname.split('/').pop() ?? '' - const body = await request.json() - // @ts-ignore - const survey = await prisma.SD_SURVEY_SALES_BASIC_INFO.update({ - where: { ID: Number(id) }, - data: { - SUBMISSION_STATUS: true, - SUBMISSION_DATE: new Date(), - SUBMISSION_TARGET_ID: body.targetId, - SUBMISSION_TARGET_NM: body.targetNm, - UPT_DT: new Date(), - }, - }) - return NextResponse.json({ status: HttpStatusCode.Ok, data: survey }) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) - } + const id = request.nextUrl.pathname.split('/').pop() ?? '' + const body = await request.json() + const service = new SurveySalesService({}) + + const survey = await service.tryFunction(() => service.submitSurvey(Number(id), body.targetId, body.targetNm)) + return NextResponse.json({ status: HttpStatusCode.Ok, data: survey }) } export const GET = loggerWrapper(getSurveySaleDetail) diff --git a/src/app/api/survey-sales/route.ts b/src/app/api/survey-sales/route.ts index 0f31da9..783d07f 100644 --- a/src/app/api/survey-sales/route.ts +++ b/src/app/api/survey-sales/route.ts @@ -1,156 +1,9 @@ import { NextResponse } from 'next/server' -import { prisma } from '@/libs/prisma' -import { convertToSnakeCase, ERROR_MESSAGES } from '@/utils/common-utils' +import { ERROR_MESSAGES } from '@/utils/common-utils' import { loggerWrapper } from '@/libs/api-wrapper' import { HttpStatusCode } from 'axios' -/** - * @description 검색 파라미터 타입 - */ -type SearchParams = { - keyword?: string | null - searchOption?: string | null - isMySurvey?: string | null - sort?: string | null - offset?: string | null - role?: string | null - storeId?: string | null - builderId?: string | null -} - -type WhereCondition = { - AND: any[] - OR?: any[] - [key: string]: any -} - -/** 검색 가능한 필드 옵션 */ -const SEARCH_OPTIONS = [ - 'BUILDING_NAME', - 'REPRESENTATIVE', - 'STORE', - 'STORE_ID', - 'CONSTRUCTION_POINT', - 'CONSTRUCTION_POINT_ID', - 'CUSTOMER_NAME', - 'POST_CODE', - 'ADDRESS', - 'ADDRESS_DETAIL', - 'SRL_NO', -] as const - -/** 페이지당 기본 항목 수 */ -const ITEMS_PER_PAGE = 10 - -/** - * @description 키워드 검색 조건 생성 함수 - * @param {string} keyword 검색 키워드 - * @param {string} searchOption 검색 옵션 - * @returns {WhereCondition} 검색 조건 객체 - */ -const createKeywordSearchCondition = (keyword: string, searchOption: string): WhereCondition => { - const where: WhereCondition = { AND: [] } - - if (searchOption === 'all') { - /** 모든 필드 검색 시 OR 조건 사용 */ - where.OR = [] - - where.OR.push( - ...SEARCH_OPTIONS.map((field) => ({ - [field]: { contains: keyword }, - })), - ) - } else if (SEARCH_OPTIONS.includes(searchOption.toUpperCase() as any)) { - /** 특정 필드 검색 */ - where[searchOption.toUpperCase()] = { contains: keyword } - } - return where -} - -/** - * @description 회원 역할별 검색 조건 생성 함수 - * @param {SearchParams} params 검색 파라미터 - * @returns {WhereCondition} 검색 조건 객체 - */ -const createMemberRoleCondition = (params: SearchParams): WhereCondition => { - const where: WhereCondition = { AND: [] } - - switch (params.role) { - case 'Admin': - where.OR = [ - { - AND: [{ STORE_ID: { equals: params.storeId } }], - }, - { - AND: [{ SUBMISSION_TARGET_ID: { equals: params.storeId } }, { SUBMISSION_STATUS: { equals: true } }], - }, - ] - break - - case 'Admin_Sub': - where.OR = [ - { - AND: [{ STORE_ID: { equals: params.storeId } }, { CONSTRUCTION_POINT_ID: { equals: params.builderId } }], - }, - { - AND: [ - { SUBMISSION_TARGET_ID: { equals: params.storeId } }, - { CONSTRUCTION_POINT_ID: { not: null } }, - { CONSTRUCTION_POINT_ID: { not: '' } }, - { SUBMISSION_STATUS: { equals: true } }, - ], - }, - ] - break - - case 'Builder': - case 'Partner': - where.AND?.push({ - CONSTRUCTION_POINT_ID: { equals: params.builderId }, - }) - break - - case 'T01': - where.OR = [ - { - NOT: { - SRL_NO: { - startsWith: '一時保存', - }, - }, - }, - { - STORE_ID: { - equals: params.storeId, - }, - }, - ] - break - case 'User': - break - } - - return where -} -/** - * @description 권한 별 필수 값 존재 여부 확인, 없을 시 빈 데이터 반환 - * @param {SearchParams} params 검색 파라미터 - * @returns {NextResponse} 세션 체크 결과 - */ -const checkSession = (params: SearchParams) => { - if (params.role === null) { - return NextResponse.json({ data: [], count: 0 }) - } - if (params.role === 'Builder' || params.role === 'Partner') { - if (params.builderId === null) { - return NextResponse.json({ data: [], count: 0 }) - } - } else { - if (params.storeId === null) { - return NextResponse.json({ data: [], count: 0 }) - } - } - return null -} +import { SurveySearchParams } from '@/types/Survey' +import { SurveySalesService } from './service' /** * @api {GET} /api/survey-sales 설문 목록 조회 API @@ -195,108 +48,28 @@ const checkSession = (params: SearchParams) => { * */ async function getSurveySales(request: Request) { - try { - /** URL 파라미터 파싱 */ - const { searchParams } = new URL(request.url) - const params: SearchParams = { - keyword: searchParams.get('keyword'), - searchOption: searchParams.get('searchOption'), - isMySurvey: searchParams.get('isMySurvey'), - sort: searchParams.get('sort'), - offset: searchParams.get('offset'), - role: searchParams.get('role'), - storeId: searchParams.get('storeId'), - builderId: searchParams.get('builderId'), - } - - /** 세션 체크 결과 처리 */ - const sessionCheckResult = checkSession(params) - if (sessionCheckResult) { - return sessionCheckResult - } - - /** 검색 조건 구성 */ - const where: WhereCondition = { AND: [] } - - /** 내가 작성한 매물 조건 적용 */ - if (params.isMySurvey) { - where.AND.push({ REPRESENTATIVE_ID: params.isMySurvey }) - } - - /** 키워드 검색 조건 적용 */ - if (params.keyword && params.searchOption) { - where.AND.push(createKeywordSearchCondition(params.keyword, params.searchOption)) - } - - /** 회원 유형 조건 적용 */ - const roleCondition = createMemberRoleCondition(params) - if (Object.keys(roleCondition).length > 0) { - where.AND.push(roleCondition) - } - /** 페이지네이션 데이터 조회 */ - //@ts-ignore - const surveys = await prisma.SD_SURVEY_SALES_BASIC_INFO.findMany({ - where, - orderBy: params.sort === 'created' ? { REG_DT: 'desc' } : { UPT_DT: 'desc' }, - skip: Number(params.offset), - take: ITEMS_PER_PAGE, - }) - /** 전체 개수만 조회 */ - //@ts-ignore - const count = await prisma.SD_SURVEY_SALES_BASIC_INFO.count({ where }) - return NextResponse.json({ data: { data: surveys, count: count } }) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + /** URL 파라미터 파싱 */ + const { searchParams } = new URL(request.url) + const params: SurveySearchParams = { + keyword: searchParams.get('keyword'), + searchOption: searchParams.get('searchOption'), + isMySurvey: searchParams.get('isMySurvey'), + sort: searchParams.get('sort'), + offset: searchParams.get('offset'), + role: searchParams.get('role'), + storeId: searchParams.get('storeId'), + builderId: searchParams.get('builderId'), } -} + const surveySalesService = new SurveySalesService(params) -/** - * @api {PUT} /api/survey-sales 설문 상세 정보 추가 API - * @apiName PUT /api/survey-sales - * @apiGroup SurveySales - * @apiDescription 설문 상세 정보 추가 API - * - * @apiParam {Number} id 설문 목록 ID (required) - * @apiBody {Object} detail_info 상세 정보 (required) - * - * @apiSuccess {String} message 성공 메시지 - * - * @apiExample {curl} Example usage: - * curl -X PUT \ - * -H "Content-Type: application/json" \ - * -d '{"id": 1, "detail_info": {"memo": "1234567890"}}' \ - * http://localhost:3000/api/survey-sales - * - * @apiSuccessExample {json} Success-Response: - * { - * "message": "Success Update Survey" - * } - * - * @apiError {Number} 500 서버 오류 - */ -async function updateSurveySales(request: Request) { - try { - /** 요청 바디 파싱 */ - const body = await request.json() - - /** 상세 정보 생성을 위한 데이터 구성 */ - const detailInfo = { - ...body.detail_info, - BASIC_INFO_ID: body.id, - } - - /** 상세 정보 생성 */ - //@ts-ignore - await prisma.SD_SURVEY_SALES_DETAIL_INFO.create({ - data: detailInfo, - }) - - return NextResponse.json({ status: HttpStatusCode.Ok }) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + /** 세션 체크 결과 처리 */ + const sessionCheckResult = surveySalesService.checkSession() + if (sessionCheckResult) { + return sessionCheckResult } + const where = surveySalesService.createFilterSurvey() + const result = await surveySalesService.tryFunction(() => surveySalesService.getSurveySales(where)) + return NextResponse.json({ data: result }) } /** @@ -336,64 +109,11 @@ async function updateSurveySales(request: Request) { * @apiError {Number} 500 서버 오류 */ async function createSurveySales(request: Request) { - try { - const body = await request.json() - - const role = - body.role === 'T01' || body.role === 'Admin' - ? 'HO' - : body.role === 'Admin_Sub' || body.role === 'Builder' - ? 'HM' - : body.role === 'Partner' - ? '' - : null - - /** 임시 저장 시 임시저장으로 저장 */ - /** 기본 저장 시 (HO/HM) + 판매점ID + yyMMdd + 000 으로 저장 */ - const baseSrlNo = - body.survey.srlNo ?? - role + - body.storeId + - new Date().getFullYear().toString().slice(-2) + - (new Date().getMonth() + 1).toString().padStart(2, '0') + - new Date().getDate().toString().padStart(2, '0') - - // @ts-ignore - const lastSurvey = await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ - where: { - SRL_NO: { - startsWith: role + body.storeId, - }, - }, - orderBy: { - SRL_NO: 'desc', - }, - }) - - /** 마지막 번호 추출 */ - const lastNumber = lastSurvey ? parseInt(lastSurvey.SRL_NO.slice(-3)) : 0 - - /** 새로운 srlNo 생성 - 임시저장일 경우 '임시저장' 으로 저장 */ - const newSrlNo = baseSrlNo.startsWith('一時保存') ? baseSrlNo : baseSrlNo + (lastNumber + 1).toString().padStart(3, '0') - - const { detailInfo, ...basicInfo } = body.survey - // @ts-ignore - const result = await prisma.SD_SURVEY_SALES_BASIC_INFO.create({ - data: { - ...convertToSnakeCase(basicInfo), - SRL_NO: newSrlNo, - DETAIL_INFO: { - create: convertToSnakeCase(detailInfo), - }, - }, - }) - return NextResponse.json(result) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) - } + const body = await request.json() + const surveySalesService = new SurveySalesService({}) + const result = await surveySalesService.tryFunction(() => surveySalesService.createSurvey(body.survey, body.role, body.storeId)) + return NextResponse.json(result) } export const GET = loggerWrapper(getSurveySales) -export const PUT = loggerWrapper(updateSurveySales) -export const POST = loggerWrapper(createSurveySales) \ No newline at end of file +export const POST = loggerWrapper(createSurveySales) diff --git a/src/app/api/survey-sales/service.ts b/src/app/api/survey-sales/service.ts new file mode 100644 index 0000000..0e60556 --- /dev/null +++ b/src/app/api/survey-sales/service.ts @@ -0,0 +1,388 @@ +import { prisma } from '@/libs/prisma' +import { SurveyBasicInfo, SurveyRegistRequest, SurveySearchParams } from '@/types/Survey' +import { convertToSnakeCase, ERROR_MESSAGES } from '@/utils/common-utils' +import { NextResponse } from 'next/server' +import { Prisma } from '@prisma/client' +import type { SessionData } from '@/types/Auth' +import { HttpStatusCode } from 'axios' + +type WhereCondition = { + AND: any[] + OR?: any[] + [key: string]: any +} + +/** 검색 옵션 */ +const SEARCH_OPTIONS = [ + 'BUILDING_NAME', + 'REPRESENTATIVE', + 'STORE', + 'STORE_ID', + 'CONSTRUCTION_POINT', + 'CONSTRUCTION_POINT_ID', + 'CUSTOMER_NAME', + 'POST_CODE', + 'ADDRESS', + 'ADDRESS_DETAIL', + 'SRL_NO', +] as const + +/** 페이지당 기본 항목 수 */ +const ITEMS_PER_PAGE = 10 + +/** + * @description 조사 매물 서비스 + * @param {SurveySearchParams} params 검색 파라미터 + */ +export class SurveySalesService { + private params!: SurveySearchParams + + /** + * @description 생성자 + * @param {SurveySearchParams} params 검색 파라미터 + */ + constructor(params: SurveySearchParams) { + this.params = params + } + + /** + * @description 권한 별 필수 값 존재 여부 확인, 없을 시 빈 데이터 반환 + * @param {SearchParams} params 검색 파라미터 + * @returns {NextResponse} 세션 체크 결과 + */ + checkSession() { + if (this.params.role === null) { + return NextResponse.json({ data: [], count: 0 }) + } + if (this.params.role === 'Builder' || this.params.role === 'Partner') { + if (this.params.builderId === null) { + return NextResponse.json({ data: [], count: 0 }) + } + } else { + if (this.params.storeId === null) { + return NextResponse.json({ data: [], count: 0 }) + } + } + return null + } + + /** + * @description 내가 작성한 매물 조건 생성 + * @returns {WhereCondition} 내가 작성한 매물 조건 + */ + private createMySurveyCondition(): WhereCondition { + if (!this.params.isMySurvey) return { AND: [] } + return { AND: [{ REPRESENTATIVE_ID: this.params.isMySurvey }] } + } + + /** + * @description 키워드 검색 조건 생성 + * @returns {WhereCondition} 키워드 검색 조건 + */ + private createKeywordCondition(): WhereCondition { + if (!this.params.keyword || !this.params.searchOption) return { AND: [] } + + const where: WhereCondition = { AND: [] } + if (this.params.searchOption === 'all') { + where.OR = SEARCH_OPTIONS.map((field) => ({ + [field]: { contains: this.params.keyword }, + })) + } else if (SEARCH_OPTIONS.includes(this.params.searchOption?.toUpperCase() as (typeof SEARCH_OPTIONS)[number])) { + where[this.params.searchOption?.toUpperCase() as (typeof SEARCH_OPTIONS)[number]] = { contains: this.params.keyword } + } + return where + } + + /** + * @description 역할 기반 조건 생성 + * @returns {WhereCondition} 역할 기반 조건 + * @exampleResult { AND: [{ STORE_ID: { equals: '1234567890' } }] } + * + * @description T01 : 임시저장되지 않은 전체 매물 조회 + * @description Admin : 같은 판매점에서 작성된 매물, 2차점에게 제출받은 매물 조회 + * @description Admin_Sub : 같은 판매점에서 작성된 매물, 시공권한 user에게 제출받은 매물 조회 + * @description Builder : 같은 시공점에서 작성된 매물 조회 + * @description Partner : 같은 시공점에서 작성된 매물 조회 + */ + private createRoleCondition(): WhereCondition { + const where: WhereCondition = { AND: [] } + + switch (this.params.role) { + case 'Admin': + where.OR = [ + { AND: [{ STORE_ID: { equals: this.params.storeId } }] }, + { AND: [{ SUBMISSION_TARGET_ID: { equals: this.params.storeId } }, { SUBMISSION_STATUS: { equals: true } }] }, + ] + break + case 'Admin_Sub': + where.OR = [ + { AND: [{ STORE_ID: { equals: this.params.storeId } }, { CONSTRUCTION_POINT_ID: { equals: this.params.builderId } }] }, + { + AND: [ + { SUBMISSION_TARGET_ID: { equals: this.params.storeId } }, + { CONSTRUCTION_POINT_ID: { not: null } }, + { CONSTRUCTION_POINT_ID: { not: '' } }, + { SUBMISSION_STATUS: { equals: true } }, + ], + }, + ] + break + case 'Builder': + case 'Partner': + where.AND.push({ CONSTRUCTION_POINT_ID: { equals: this.params.builderId } }) + break + case 'T01': + where.OR = [{ NOT: { SRL_NO: { startsWith: '一時保存' } } }, { STORE_ID: { equals: this.params.storeId } }] + break + } + return where + } + + /** + * @description 조사 매물 검색 조건 생성 + * @returns {WhereCondition} 조사 매물 검색 조건 + */ + createFilterSurvey(): WhereCondition { + const where: WhereCondition = { AND: [] } + + /** 내가 작성한 매물 조건 */ + const mySurveyCondition = this.createMySurveyCondition() + if (mySurveyCondition.AND.length > 0) { + where.AND.push(mySurveyCondition) + } + + /** 키워드 검색 조건 */ + const keywordCondition = this.createKeywordCondition() + if (Object.keys(keywordCondition).length > 0) { + where.AND.push(keywordCondition) + } + + /** 역할 기반 조건 */ + const roleCondition = this.createRoleCondition() + if (Object.keys(roleCondition).length > 0) { + where.AND.push(roleCondition) + } + + return where + } + + /** + * @description 조사 매물 검색 + * @param {WhereCondition} where 조사 매물 검색 조건 + * @returns {Promise<{ data: SurveyBasicInfo[], count: number }>} 조사 매물 데이터 + */ + async getSurveySales(where: WhereCondition) { + /** 조사 매물 조회 */ + //@ts-ignore + const surveys = await prisma.SD_SURVEY_SALES_BASIC_INFO.findMany({ + where, + orderBy: this.params.sort === 'created' ? { REG_DT: 'desc' } : { UPT_DT: 'desc' }, + skip: Number(this.params.offset), + take: ITEMS_PER_PAGE, + }) + + /** 조사 매물 개수 조회 */ + //@ts-ignore + const count = await prisma.SD_SURVEY_SALES_BASIC_INFO.count({ where }) + return { data: surveys as SurveyBasicInfo[], count } + } + + /** + * @description 조사 매물 생성 + * @param {SurveyRegistRequest} survey 조사 매물 데이터 + * @param {string} role 권한 + * @param {string} storeId 판매점 ID + * @returns {Promise} 생성된 조사 매물 데이터 + */ + async createSurvey(survey: SurveyRegistRequest, role: string, storeId: string) { + const { detailInfo, ...basicInfo } = survey + const newSrlNo = survey.srlNo ?? (await this.getNewSrlNo(storeId, role)) + // @ts-ignore + return await prisma.SD_SURVEY_SALES_BASIC_INFO.create({ + data: { + ...convertToSnakeCase(basicInfo), + SRL_NO: newSrlNo, + DETAIL_INFO: { + create: convertToSnakeCase(detailInfo), + }, + }, + }) + } + + /** + * @description 새로운 srlNo 생성 함수 + * @param {string} role 세션에 저장된 권한 + * @param {string} tempSrlNo 임시 srlNo (임시저장 시 사용) + * @param {string} storeId 세션에 저장된 판매점 ID + * @returns {Promise} 새로운 srlNo + * + * @exampleResult HO250617001 (HO + 250617 + 001) + */ + async getNewSrlNo(storeId: string, role: string) { + const srlRole = role === 'T01' || role === 'Admin' ? 'HO' : role === 'Admin_Sub' || role === 'Builder' ? 'HM' : '' + + //@ts-ignore + const index = await prisma.SD_SURVEY_SALES_BASIC_INFO.count({ + where: { + SRL_NO: { + startsWith: srlRole + storeId, + }, + }, + }) + + return ( + srlRole + + storeId + + new Date().getFullYear().toString().slice(-2) + + (new Date().getMonth() + 1).toString().padStart(2, '0') + + new Date().getDate().toString().padStart(2, '0') + + (index + 1).toString().padStart(3, '0') + ) + } + + /** + * @description 조사 매물 상세 조회 + * @param {number} id 조사 매물 ID + * @returns {Promise} 조사 매물 데이터 + */ + async fetchSurvey(id: number) { + // @ts-ignore + return (await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ + where: { ID: id }, + include: { DETAIL_INFO: true }, + })) as SurveyBasicInfo + } + + /** + * @description 조사 매물 수정 + * @param {number} id 조사 매물 ID + * @param {SurveyRegistRequest} survey 조사 매물 데이터 + * @param {boolean} isTemporary 임시 저장 여부 + * @param {string} storeId 판매점 ID + * @param {string} role 권한 + * @returns {Promise} 수정된 조사 매물 데이터 + */ + async updateSurvey(id: number, survey: SurveyRegistRequest, isTemporary: boolean, storeId: string, role: string) { + const { detailInfo, ...basicInfo } = survey + const newSrlNo = isTemporary ? survey.srlNo ?? '一時保存' : await this.getNewSrlNo(storeId, role) + + // @ts-ignore + return (await prisma.SD_SURVEY_SALES_BASIC_INFO.update({ + where: { ID: Number(id) }, + data: { + ...convertToSnakeCase(basicInfo), + SRL_NO: newSrlNo, + UPT_DT: new Date(), + DETAIL_INFO: { + update: convertToSnakeCase(detailInfo), + }, + }, + include: { + DETAIL_INFO: true, + }, + })) as SurveyBasicInfo + } + + /** + * @description 조사 매물 삭제 + * @param {number} id 조사 매물 ID + */ + async deleteSurvey(id: number) { + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { + // @ts-ignore + const detailData = await tx.SD_SURVEY_SALES_BASIC_INFO.findUnique({ + where: { ID: Number(id) }, + select: { + DETAIL_INFO: true, + }, + }) + + if (detailData?.DETAIL_INFO?.ID) { + // @ts-ignore + await tx.SD_SURVEY_SALES_DETAIL_INFO.delete({ + where: { ID: Number(detailData.DETAIL_INFO.ID) }, + }) + } + + // @ts-ignore + await tx.SD_SURVEY_SALES_BASIC_INFO.delete({ + where: { ID: Number(id) }, + }) + }) + } + + /** + * @description 조사 매물 제출 + * @param {number} id 조사 매물 ID + * @param {string} targetId 제출 대상 ID + * @param {string} targetNm 제출 대상 이름 + * @returns {Promise} 제출된 조사 매물 데이터 + */ + async submitSurvey(id: number, targetId: string, targetNm: string) { + // @ts-ignore + return (await prisma.SD_SURVEY_SALES_BASIC_INFO.update({ + where: { ID: Number(id) }, + data: { + SUBMISSION_STATUS: true, + SUBMISSION_DATE: new Date(), + SUBMISSION_TARGET_ID: targetId, + SUBMISSION_TARGET_NM: targetNm, + UPT_DT: new Date(), + }, + })) as SurveyBasicInfo + } + + /** + * @description 권한 체크 + * @param {any} survey 조사 매물 데이터 + * @param {SessionData} session 세션 데이터 + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ + checkRole(survey: any, session: SessionData): boolean { + if (!survey || !session.isLoggedIn) return false + + const roleChecks = { + T01: () => this.checkT01Role(survey), + Admin: () => this.checkAdminRole(survey, session.storeId), + Admin_Sub: () => this.checkAdminSubRole(survey, session.storeId), + Partner: () => this.checkPartnerOrBuilderRole(survey, session.builderId), + Builder: () => this.checkPartnerOrBuilderRole(survey, session.builderId), + } + + return roleChecks[session.role as keyof typeof roleChecks]?.() ?? false + } + + private checkT01Role(survey: any): boolean { + return survey.SRL_NO !== '一時保存' + } + + private checkAdminRole(survey: any, storeId: string | null): boolean { + if (!storeId) return false + return survey.SUBMISSION_STATUS ? survey.SUBMISSION_TARGET_ID === storeId || survey.STORE_ID === storeId : survey.STORE_ID === storeId + } + + private checkAdminSubRole(survey: any, storeId: string | null): boolean { + if (!storeId) return false + return survey.SUBMISSION_STATUS + ? survey.SUBMISSION_TARGET_ID === storeId || (survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID) + : survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID + } + + private checkPartnerOrBuilderRole(survey: any, builderId: string | null): boolean { + if (!builderId) return false + return survey.CONSTRUCTION_POINT_ID === builderId + } + + handleRouteError(error: unknown): NextResponse { + console.error('❌ API ROUTE ERROR : ', error) + return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + } + + async tryFunction(func: () => Promise): Promise { + try { + return await func() + } catch (error) { + return this.handleRouteError(error) + } + } +} diff --git a/src/hooks/useSurvey.ts b/src/hooks/useSurvey.ts index d6581f9..99bd072 100644 --- a/src/hooks/useSurvey.ts +++ b/src/hooks/useSurvey.ts @@ -106,10 +106,15 @@ export function useSurvey( * @param {any} error 에러 객체 * @returns {void} 라우팅 처리 */ - const errorRouter = (error: any) => { + const handleError = (error: any, isThrow?: boolean) => { const status = error.response?.status - if (error.response?.data.error) { - alert(error.response?.data.error) + const errorMsg = error.response?.data.error + console.error('❌ API ERROR : ', error) + if (errorMsg) { + alert(errorMsg) + } + if (isThrow) { + throw new Error(error) } switch (status) { /** session 없는 경우 */ @@ -164,7 +169,7 @@ export function useSurvey( }) return resp.data } catch (error: any) { - errorRouter(error) + handleError(error, false) return { data: [], count: 0 } } }, @@ -208,7 +213,7 @@ export function useSurvey( }) return resp.data } catch (error: any) { - errorRouter(error) + handleError(error, false) return null } }, @@ -234,6 +239,9 @@ export function useSurvey( queryClient.invalidateQueries({ queryKey: ['survey', 'list'] }) queryClient.invalidateQueries({ queryKey: ['survey', id] }) }, + onError: (error: any) => { + handleError(error, true) + }, }) /** @@ -248,7 +256,7 @@ export function useSurvey( */ const { mutate: updateSurvey, isPending: isUpdatingSurvey } = useMutation({ mutationFn: async ({ survey, isTemporary, storeId }: { survey: SurveyRegistRequest; isTemporary: boolean; storeId?: string | null }) => { - if (id === undefined) throw new Error('id is required') + if (id === undefined) throw new Error() const resp = await axiosInstance(null).put(`/api/survey-sales/${id}`, { survey: survey, isTemporary: isTemporary, @@ -262,7 +270,7 @@ export function useSurvey( queryClient.invalidateQueries({ queryKey: ['survey', 'list'] }) }, onError: (error: any) => { - alert(error.response?.data.error) + handleError(error, true) }, }) @@ -278,7 +286,7 @@ export function useSurvey( */ const { mutateAsync: deleteSurvey, isPending: isDeletingSurvey } = useMutation({ mutationFn: async () => { - if (id === null) throw new Error('id is required') + if (id === null) throw new Error() const resp = await axiosInstance(null).delete(`/api/survey-sales/${id}`) return resp.data }, @@ -301,7 +309,7 @@ export function useSurvey( */ const { mutateAsync: submitSurvey, isPending: isSubmittingSurvey } = useMutation({ mutationFn: async ({ targetId, targetNm }: { targetId?: string | null; targetNm?: string | null }) => { - if (!id) throw new Error('id is required') + if (!id) throw new Error() const resp = await axiosInstance(null).patch(`/api/survey-sales/${id}`, { targetId, targetNm, @@ -313,7 +321,7 @@ export function useSurvey( queryClient.invalidateQueries({ queryKey: ['survey', id] }) }, onError: (error: any) => { - alert(error.response?.data.error) + handleError(error, true) }, }) @@ -376,9 +384,8 @@ export function useSurvey( ) return data.results } catch (error: any) { - console.error('Failed to fetch zipcode data:', error) - alert(error.response?.data.error) - throw new Error('Failed to fetch zipcode data') + handleError(error, true) + return null } } @@ -406,7 +413,7 @@ export function useSurvey( const { data } = await axiosInstance(null).get(endpoint) return data } catch (error: any) { - alert(error.response?.data.error) + handleError(error, true) return null } } diff --git a/src/types/Survey.ts b/src/types/Survey.ts index d46dae7..a653163 100644 --- a/src/types/Survey.ts +++ b/src/types/Survey.ts @@ -301,3 +301,25 @@ export type SubmitTargetResponse = { /* 권한 */ auth: string } + +/** + * @description 조사매물 검색 파라미터 타입 + */ +export type SurveySearchParams = { + /** 검색 키워드 */ + keyword?: string | null + /** 검색 옵션 */ + searchOption?: string | null + /** 내 조사매물 여부 */ + isMySurvey?: string | null + /** 정렬 옵션 */ + sort?: string | null + /** 페이지 번호 */ + offset?: string | null + /** 권한 */ + role?: string | null + /** 판매점 ID */ + storeId?: string | null + /** 시공점 ID */ + builderId?: string | null +} From 12b9dd42161c8b73d536c0232c9ed96badb19210 Mon Sep 17 00:00:00 2001 From: keyy1315 Date: Wed, 18 Jun 2025 10:43:12 +0900 Subject: [PATCH 5/8] refactor: enhance error handling and user feedback in survey components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API try-catch 구문 함수 구현 - 조사 매물 alert 처리 리팩토링 --- src/app/api/qna/file/route.ts | 18 +++ src/app/api/submission/route.ts | 23 ++- src/app/api/submission/service.ts | 16 ++ .../survey-sale/detail/ButtonForm.tsx | 32 ++-- .../survey-sale/detail/RoofForm.tsx | 12 +- .../survey-sale/list/SearchForm.tsx | 6 +- src/hooks/useSurvey.ts | 140 +++++++++++------- src/types/Survey.ts | 29 ++++ 8 files changed, 188 insertions(+), 88 deletions(-) diff --git a/src/app/api/qna/file/route.ts b/src/app/api/qna/file/route.ts index e050c3c..8d6ea8a 100644 --- a/src/app/api/qna/file/route.ts +++ b/src/app/api/qna/file/route.ts @@ -3,6 +3,24 @@ import { NextResponse } from 'next/server' import { loggerWrapper } from '@/libs/api-wrapper' import { ERROR_MESSAGES } from '@/utils/common-utils' +/** + * @api {GET} /api/qna/file 문의 첨부 파일 다운로드 API + * @apiName GET /api/qna/file + * @apiGroup Qna + * @apiDescription 문의 첨부 파일 다운로드 API + * + * @apiParam {String} encodeFileNo 인코딩 파일 번호 + * @apiParam {String} srcFileNm 소스 파일 이름 + * + * @apiExample {curl} Example usage: + * curl -X GET http://localhost:3000/api/qna/file?encodeFileNo=1234567890&srcFileNm=test.pdf + * + * @apiSuccessExample {octet-stream} Success-Response: + * file content + * + * @apiError {Number} 500 서버 오류 + * @apiError {Number} 400 잘못된 요청 + */ async function downloadFile(request: Request): Promise { const { searchParams } = new URL(request.url) const encodeFileNo = searchParams.get('encodeFileNo') diff --git a/src/app/api/submission/route.ts b/src/app/api/submission/route.ts index e0b62ee..8e1acd1 100644 --- a/src/app/api/submission/route.ts +++ b/src/app/api/submission/route.ts @@ -41,22 +41,17 @@ import { loggerWrapper } from '@/libs/api-wrapper' * @apiError {String} error.message 에러 메시지 */ async function getSubmitTargetData(request: NextRequest): Promise { - try { - const { searchParams } = new URL(request.url) - const storeId = searchParams.get('storeId') - const role = searchParams.get('role') + const { searchParams } = new URL(request.url) + const storeId = searchParams.get('storeId') + const role = searchParams.get('role') - if (!storeId || !role) { - return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) - } - - const submissionService = new SubmissionService(storeId, role) - const data = await submissionService.getSubmissionTarget() - return NextResponse.json(data) - } catch (error) { - console.error('❌ API ROUTE ERROR:', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + if (!storeId || !role) { + return NextResponse.json({ error: ERROR_MESSAGES.BAD_REQUEST }, { status: HttpStatusCode.BadRequest }) } + + const submissionService = new SubmissionService(storeId, role) + const data = await submissionService.tryFunction(() => submissionService.getSubmissionTarget()) + return NextResponse.json(data) } export const GET = loggerWrapper(getSubmitTargetData) diff --git a/src/app/api/submission/service.ts b/src/app/api/submission/service.ts index 7f7c86e..12ccbb8 100644 --- a/src/app/api/submission/service.ts +++ b/src/app/api/submission/service.ts @@ -1,5 +1,8 @@ import { prisma } from '@/libs/prisma' +import { ERROR_MESSAGES } from '@/utils/common-utils' import { SubmitTargetResponse } from '@/types/Survey' +import { HttpStatusCode } from 'axios' +import { NextResponse } from 'next/server' export class SubmissionService { private storeId: string @@ -70,4 +73,17 @@ export class SubmissionService { ` return await prisma.$queryRawUnsafe(this.BASE_QUERY + query, this.storeId) } + + handleRouteError(error: unknown): NextResponse { + console.error('❌ API ROUTE ERROR : ', error) + return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + } + + async tryFunction(func: () => Promise): Promise { + try { + return await func() + } catch (error) { + return this.handleRouteError(error) + } + } } diff --git a/src/components/survey-sale/detail/ButtonForm.tsx b/src/components/survey-sale/detail/ButtonForm.tsx index c806139..5ca80a5 100644 --- a/src/components/survey-sale/detail/ButtonForm.tsx +++ b/src/components/survey-sale/detail/ButtonForm.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { requiredFields, useSurvey } from '@/hooks/useSurvey' import { usePopupController } from '@/store/popupController' +import { ALERT_MESSAGES } from '@/types/Survey' interface ButtonFormProps { mode: Mode @@ -48,7 +49,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { const isSubmit = data.basic.submissionStatus const { deleteSurvey, updateSurvey, isDeletingSurvey, isUpdatingSurvey } = useSurvey(id) - const { validateSurveyDetail, createSurvey, isCreatingSurvey } = useSurvey() + const { validateSurveyDetail, createSurvey, isCreatingSurvey, showSurveyAlert, showSurveyConfirm } = useSurvey() useEffect(() => { if (!session?.isLoggedIn) return @@ -116,7 +117,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { router.push(`/survey-sale/${savedId}`) } } - alert('一時保存されました。') + showSurveyAlert(ALERT_MESSAGES.TEMP_SAVE_SUCCESS) } /** 입력 필드 포커스 처리 */ @@ -128,7 +129,13 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 저장 로직 */ const saveProcess = async (emptyField: string | null, isSubmitProcess?: boolean) => { if (emptyField?.trim() === '') { - await handleSuccessfulSave(isSubmitProcess) + if (!isSubmitProcess) { + showSurveyConfirm(ALERT_MESSAGES.SAVE_CONFIRM, async () => { + await handleSuccessfulSave(isSubmitProcess) + }) + } else { + await handleSuccessfulSave(isSubmitProcess) + } } else { handleFailedSave(emptyField) } @@ -147,6 +154,8 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { setMode('READ') if (isSubmitProcess) { popupController.setSurveySaleSubmitPopup(true) + } else { + showSurveyAlert(ALERT_MESSAGES.SAVE_SUCCESS) } } } else { @@ -156,7 +165,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { await router.push(`/survey-sale/${savedId}?show=true`) } else { await router.push(`/survey-sale/${savedId}`) - alert('保存されました。') + showSurveyAlert(ALERT_MESSAGES.SAVE_SUCCESS) } } } @@ -164,9 +173,10 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 필수값 미입력 처리 */ const handleFailedSave = (emptyField: string | null) => { if (emptyField?.includes('Unit')) { - alert('電気契約容量の単位を入力してください。') + showSurveyAlert(ALERT_MESSAGES.UNIT_REQUIRED) } else { - alert(requiredFields.find((field) => field.field === emptyField)?.name + ' 項目が空です。') + const fieldInfo = requiredFields.find((field) => field.field === emptyField) + showSurveyAlert(ALERT_MESSAGES.REQUIRED_FIELD, fieldInfo?.name || '') } focusInput(emptyField as keyof SurveyDetailInfo) } @@ -174,10 +184,10 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 삭제 로직 */ const handleDelete = async () => { if (!Number.isNaN(id)) { - window.neoConfirm('削除しますか?', async () => { + showSurveyConfirm(ALERT_MESSAGES.DELETE_CONFIRM, async () => { await deleteSurvey() if (!isDeletingSurvey) { - alert('削除されました。') + showSurveyAlert(ALERT_MESSAGES.DELETE_SUCCESS) router.push('/survey-sale') } }) @@ -187,16 +197,16 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 제출 로직 */ const handleSubmit = async () => { if (data.basic.srlNo?.startsWith('一時保存') && Number.isNaN(id)) { - alert('一時保存されたデータは提出できません。') + showSurveyAlert(ALERT_MESSAGES.TEMP_SAVE_SUBMIT_ERROR) return } if (mode === 'READ') { - window.neoConfirm('提出しますか?', async () => { + showSurveyConfirm(ALERT_MESSAGES.SUBMIT_CONFIRM, async () => { popupController.setSurveySaleSubmitPopup(true) }) } else { - window.neoConfirm('記入した情報を保存して送信しますか?', async () => { + showSurveyConfirm(ALERT_MESSAGES.SAVE_AND_SUBMIT_CONFIRM, async () => { handleSave(false, true) }) } diff --git a/src/components/survey-sale/detail/RoofForm.tsx b/src/components/survey-sale/detail/RoofForm.tsx index e19d047..36527ae 100644 --- a/src/components/survey-sale/detail/RoofForm.tsx +++ b/src/components/survey-sale/detail/RoofForm.tsx @@ -1,5 +1,7 @@ import { useState } from 'react' import type { Mode, SurveyDetailInfo, SurveyDetailRequest } from '@/types/Survey' +import { useSurvey } from '@/hooks/useSurvey' +import { ALERT_MESSAGES } from '@/types/Survey' type RadioEtcKeys = | 'structureOrder' @@ -247,6 +249,7 @@ export default function RoofForm(props: { mode: Mode }) { const { roofInfo, setRoofInfo, mode } = props + const { showSurveyAlert } = useSurvey() const [isFlip, setIsFlip] = useState(true) const handleNumberInput = (key: keyof SurveyDetailRequest, value: number | string) => { @@ -254,13 +257,13 @@ export default function RoofForm(props: { if (key === 'roofSlope' || key === 'openFieldPlateThickness') { const stringValue = value.toString() if (stringValue.length > 5) { - alert('保存できるサイズを超えました。') + showSurveyAlert('保存できるサイズを超えました。') return } if (stringValue.includes('.')) { const decimalPlaces = stringValue.split('.')[1].length if (decimalPlaces > 1) { - alert('小数点以下1桁までしか許されません。') + showSurveyAlert('小数点以下1桁までしか許されません。') return } } @@ -732,6 +735,7 @@ const MultiCheck = ({ roofInfo: SurveyDetailInfo setRoofInfo: (roofInfo: SurveyDetailRequest) => void }) => { + const { showSurveyAlert } = useSurvey() const multiCheckData = column === 'supplementaryFacilities' ? supplementaryFacilities : roofMaterial const etcValue = roofInfo?.[`${column}Etc` as keyof SurveyDetailInfo] const [isOtherCheck, setIsOtherCheck] = useState(Boolean(etcValue)) @@ -751,7 +755,7 @@ const MultiCheck = ({ if (isRoofMaterial) { const totalSelected = selectedValues.length + (isOtherSelected || isOtherCheck ? 1 : 0) if (totalSelected >= 2) { - alert('屋根材は最大2個まで選択できます。') + showSurveyAlert(ALERT_MESSAGES.ROOF_MATERIAL_MAX_SELECT_ERROR) return } } @@ -765,7 +769,7 @@ const MultiCheck = ({ if (isRoofMaterial) { const currentSelected = selectedValues.length if (!isOtherCheck && currentSelected >= 2) { - alert('屋根材は最大2個まで選択できます。') + showSurveyAlert(ALERT_MESSAGES.ROOF_MATERIAL_MAX_SELECT_ERROR) return } } diff --git a/src/components/survey-sale/list/SearchForm.tsx b/src/components/survey-sale/list/SearchForm.tsx index 83943a9..86ad3c0 100644 --- a/src/components/survey-sale/list/SearchForm.tsx +++ b/src/components/survey-sale/list/SearchForm.tsx @@ -3,16 +3,18 @@ import { SEARCH_OPTIONS, SEARCH_OPTIONS_ENUM, SEARCH_OPTIONS_PARTNERS, useSurveyFilterStore } from '@/store/surveyFilterStore' import { useRouter } from 'next/navigation' import { useState } from 'react' +import { useSurvey } from '@/hooks/useSurvey' export default function SearchForm({ memberRole, userId }: { memberRole: string; userId: string }) { const router = useRouter() + const { showSurveyAlert } = useSurvey() const { setSearchOption, setSort, setIsMySurvey, setKeyword, reset, isMySurvey, keyword, searchOption, sort, setOffset } = useSurveyFilterStore() const [searchKeyword, setSearchKeyword] = useState(keyword) const [option, setOption] = useState(searchOption) const handleSearch = () => { if (option !== 'id' && searchKeyword.trim().length < 2) { - alert('2文字以上入力してください') + showSurveyAlert('2文字以上入力してください') return } setOffset(0) @@ -62,7 +64,7 @@ export default function SearchForm({ memberRole, userId }: { memberRole: string; placeholder="タイトルを入力してください. (2文字以上)" onChange={(e) => { if (e.target.value.length > 30) { - alert('30文字以内で入力してください') + showSurveyAlert('30文字以内で入力してください') return } setSearchKeyword(e.target.value) diff --git a/src/hooks/useSurvey.ts b/src/hooks/useSurvey.ts index 99bd072..fb62253 100644 --- a/src/hooks/useSurvey.ts +++ b/src/hooks/useSurvey.ts @@ -1,4 +1,4 @@ -import type { SubmitTargetResponse, SurveyBasicInfo, SurveyDetailRequest, SurveyRegistRequest } from '@/types/Survey' +import { ALERT_MESSAGES, type SubmitTargetResponse, type SurveyBasicInfo, type SurveyDetailRequest, type SurveyRegistRequest } from '@/types/Survey' import { useMemo } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useSurveyFilterStore } from '@/store/surveyFilterStore' @@ -93,6 +93,8 @@ export function useSurvey( refetchSurveyList: () => void refetchSurveyDetail: () => void getSubmitTarget: (params: { storeId: string; role: string }) => Promise + showSurveyAlert: (message: (typeof ALERT_MESSAGES)[keyof typeof ALERT_MESSAGES] | string, requiredField?: string) => void + showSurveyConfirm: (message: string, onConfirm: () => void, onCancel?: () => void) => void } { const queryClient = useQueryClient() const { keyword, searchOption, isMySurvey, sort, offset } = useSurveyFilterStore() @@ -111,7 +113,7 @@ export function useSurvey( const errorMsg = error.response?.data.error console.error('❌ API ERROR : ', error) if (errorMsg) { - alert(errorMsg) + showSurveyAlert(errorMsg) } if (isThrow) { throw new Error(error) @@ -138,6 +140,19 @@ export function useSurvey( } } + const tryFunction = async (func: () => Promise, isList?: boolean, isThrow?: boolean): Promise => { + try { + const resp = await func() + return resp.data + } catch (error) { + handleError(error, isThrow) + if (isList) { + return { data: [], count: 0 } + } + return null + } + } + /** * @description 조사 매물 목록 조회 * @@ -154,24 +169,23 @@ export function useSurvey( } = useQuery({ queryKey: ['survey', 'list', keyword, searchOption, isMySurvey, sort, offset, session?.storeNm, session?.builderId, session?.role], queryFn: async () => { - try { - const resp = await axiosInstance(null).get<{ data: SurveyBasicInfo[]; count: number }>('/api/survey-sales', { - params: { - keyword, - searchOption, - isMySurvey, - sort, - offset, - storeId: session?.storeId, - builderId: session?.builderId, - role: session?.role, - }, - }) - return resp.data - } catch (error: any) { - handleError(error, false) - return { data: [], count: 0 } - } + return await tryFunction( + () => + axiosInstance(null).get<{ data: SurveyBasicInfo[]; count: number }>('/api/survey-sales', { + params: { + keyword, + searchOption, + isMySurvey, + sort, + offset, + storeId: session?.storeId, + builderId: session?.builderId, + role: session?.role, + }, + }), + true, + false, + ) }, }) @@ -205,17 +219,16 @@ export function useSurvey( queryKey: ['survey', id], queryFn: async () => { if (Number.isNaN(id) || id === undefined || id === 0) return null - try { - const resp = await axiosInstance(null).get(`/api/survey-sales/${id}`, { - params: { - isPdf: isPdf, - }, - }) - return resp.data - } catch (error: any) { - handleError(error, false) - return null - } + return await tryFunction( + () => + axiosInstance(null).get(`/api/survey-sales/${id}`, { + params: { + isPdf: isPdf, + }, + }), + false, + false, + ) }, enabled: id !== 0 && id !== undefined && id !== null, }) @@ -294,7 +307,7 @@ export function useSurvey( queryClient.invalidateQueries({ queryKey: ['survey', 'list'] }) }, onError: (error: any) => { - alert(error.response?.data.error) + handleError(error, true) }, }) @@ -378,15 +391,12 @@ export function useSurvey( * @throws {Error} 우편번호 검색 실패 시 에러 발생 */ const getZipCode = async (zipCode: string): Promise => { - try { - const { data } = await axiosInstance(null).get( - `https://zipcloud.ibsnet.co.jp/api/search?${queryStringFormatter({ zipcode: zipCode.trim() })}`, - ) - return data.results - } catch (error: any) { - handleError(error, true) - return null - } + const data = await tryFunction( + () => axiosInstance(null).get(`https://zipcloud.ibsnet.co.jp/api/search?${queryStringFormatter({ zipcode: zipCode.trim() })}`), + false, + true, + ) + return data ? data.results : null } /** @@ -398,24 +408,38 @@ export function useSurvey( * @returns {Promise} 제출 대상 목록 */ const getSubmitTarget = async (params: { storeId: string; role: string }): Promise => { - try { - if (!params.storeId) { - /** 판매점 ID 없는 경우 */ - alert('販売店IDがありません。') - return null - } - const endpoint = `/api/submission?storeId=${params.storeId}&role=${params.role}` - if (!endpoint) { - /** 권한 오류 */ - alert('権限が間違っています。') - return null - } - const { data } = await axiosInstance(null).get(endpoint) - return data - } catch (error: any) { - handleError(error, true) + if (!params.storeId) { + /** 판매점 ID 없는 경우 */ + showSurveyAlert('販売店IDがありません。') return null } + const endpoint = `/api/submission?storeId=${params.storeId}&role=${params.role}` + if (!endpoint) { + /** 권한 오류 */ + showSurveyAlert('権限が間違っています。') + return null + } + return await tryFunction(() => axiosInstance(null).get(endpoint), false, true) + } + + const showSurveyAlert = (message: (typeof ALERT_MESSAGES)[keyof typeof ALERT_MESSAGES] | string, requiredField?: string) => { + if (requiredField) { + alert(`${requiredField} ${message}`) + } else { + alert(message) + } + } + const showSurveyConfirm = (message: string, onConfirm: () => void, onCancel?: () => void) => { + if (window.neoConfirm) { + window.neoConfirm(message, onConfirm) + } else { + const confirmed = confirm(message) + if (confirmed) { + onConfirm() + } else if (onCancel) { + onCancel() + } + } } return { @@ -436,5 +460,7 @@ export function useSurvey( getSubmitTarget, refetchSurveyList, refetchSurveyDetail, + showSurveyAlert, + showSurveyConfirm, } } diff --git a/src/types/Survey.ts b/src/types/Survey.ts index a653163..c2d80b6 100644 --- a/src/types/Survey.ts +++ b/src/types/Survey.ts @@ -323,3 +323,32 @@ export type SurveySearchParams = { /** 시공점 ID */ builderId?: string | null } + + +export const ALERT_MESSAGES = { + /** 기본 메세지 */ + /** 저장 성공 - "저장되었습니다." */ + SAVE_SUCCESS: '保存されました。', + /** 임시 저장 성공 - "임시 저장되었습니다." */ + TEMP_SAVE_SUCCESS: '一時保存されました。', + /** 삭제 성공 - "삭제되었습니다." */ + DELETE_SUCCESS: '削除されました。', + /** 제출 확인 - "제출하시겠습니까?" */ + SUBMIT_CONFIRM: '提出しますか?', + /** 저장 확인 - "저장하시겠습니까?" */ + SAVE_CONFIRM: '保存しますか?', + /** 삭제 확인 - "삭제하시겠습니까?" */ + DELETE_CONFIRM: '削除しますか?', + /** 저장 및 제출 확인 - "입력한 정보를 저장하고 보내시겠습니까?" */ + SAVE_AND_SUBMIT_CONFIRM: '記入した情報を保存して送信しますか?', + /** 임시 저장 제출 오류 - "임시 저장된 데이터는 제출할 수 없습니다." */ + TEMP_SAVE_SUBMIT_ERROR: '一時保存されたデータは提出できません。', + + /** 입력 오류 메세지 */ + /* 전기계약 용량 단위 입력 메세지 - "전기 계약 용량의 단위를 입력하세요."*/ + UNIT_REQUIRED: '電気契約容量の単位を入力してください。', + /** 필수 입력 메세지 - "항목이 비어 있습니다."*/ + REQUIRED_FIELD: '項目が空です。', + /** 최대 선택 오류 메세지 - "지붕재는 최대 2개까지 선택할 수 있습니다." */ + ROOF_MATERIAL_MAX_SELECT_ERROR: '屋根材は最大2個まで選択できます。', +} \ No newline at end of file From b116e6e5c1ac0b0fef78a63351fd76b370c1b30c Mon Sep 17 00:00:00 2001 From: keyy1315 Date: Wed, 18 Jun 2025 10:48:50 +0900 Subject: [PATCH 6/8] docs: add description annotation at error handling function --- src/hooks/useSurvey.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/hooks/useSurvey.ts b/src/hooks/useSurvey.ts index fb62253..345d4a4 100644 --- a/src/hooks/useSurvey.ts +++ b/src/hooks/useSurvey.ts @@ -140,6 +140,14 @@ export function useSurvey( } } + /** + * @description 조사 매물 try catch 처리 함수 + * + * @param {Function} func 조사 매물 API 함수 + * @param {boolean} isList 조사 매물 목록 여부 + * @param {boolean} isThrow 조사 매물 데이터 조회 에러 처리 여부 + * @returns {Promise} API 응답 데이터 + */ const tryFunction = async (func: () => Promise, isList?: boolean, isThrow?: boolean): Promise => { try { const resp = await func() @@ -422,13 +430,27 @@ export function useSurvey( return await tryFunction(() => axiosInstance(null).get(endpoint), false, true) } - const showSurveyAlert = (message: (typeof ALERT_MESSAGES)[keyof typeof ALERT_MESSAGES] | string, requiredField?: string) => { + /** + * @description 조사 매물 알림 메시지 출력 + * + * @param {string} message 알림 메시지 + * @param {string} [requiredField] 필수 필드 이름 + */ + const showSurveyAlert = (message: (typeof ALERT_MESSAGES)[keyof typeof ALERT_MESSAGES] | string, requiredField?: (typeof requiredFields)[number]['field']) => { if (requiredField) { alert(`${requiredField} ${message}`) } else { alert(message) } } + + /** + * @description 조사 매물 확인 메시지 출력 + * + * @param {string} message 확인 메시지 + * @param {Function} onConfirm 확인 함수 + * @param {Function} [onCancel] 취소 함수 + */ const showSurveyConfirm = (message: string, onConfirm: () => void, onCancel?: () => void) => { if (window.neoConfirm) { window.neoConfirm(message, onConfirm) From 94da4f3452f69ddef0336de9c07e4b68a235babe Mon Sep 17 00:00:00 2001 From: Daseul Kim Date: Wed, 18 Jun 2025 12:21:03 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20log=20write=EC=8B=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EA=B9=A8=EC=A7=80=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libs/logger.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/libs/logger.ts b/src/libs/logger.ts index 480d408..54c46fd 100644 --- a/src/libs/logger.ts +++ b/src/libs/logger.ts @@ -94,13 +94,24 @@ const dailyLogger = new DailyLogger() export const writeApiLog = async (request: NextRequest, responseStatus: number): Promise => { if (!isProduction) return + let bodyString: string | undefined + if ( + request.method === 'POST' && + (request.headers.get('content-type') === 'multipart/form-data' || request.headers.get('content-type') === 'application/x-www-form-urlencoded') + ) { + const formData = await request.formData() + bodyString = JSON.stringify(Object.fromEntries(formData)) + } else { + bodyString = await request.text() + } + const logData: ApiLogData = { responseStatus: responseStatus, method: request.method, url: request.url, // headers: Object.fromEntries(request.headers), query: Object.fromEntries(new URL(request.url).searchParams), - body: request.body ? await request.text() : undefined, + body: bodyString, } dailyLogger.info(logData, 'API Request') } From d690c3e7746cf1d46c37e111852a1c0991a9be7e Mon Sep 17 00:00:00 2001 From: keyy1315 Date: Wed, 18 Jun 2025 15:12:45 +0900 Subject: [PATCH 8/8] refactor: improve error handling in API routes and services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 에러 시 반환 타입 통일 - Prisma 에러 검증 추가 --- src/app/api/submission/route.ts | 8 +- src/app/api/submission/service.ts | 48 +++++-- src/app/api/survey-sales/[id]/route.ts | 41 +++--- src/app/api/survey-sales/route.ts | 28 +++-- src/app/api/survey-sales/service.ts | 117 ++++++++++++++---- src/components/pdf/SurveySaleDownloadPdf.tsx | 13 +- .../survey-sale/detail/ButtonForm.tsx | 24 ++-- .../survey-sale/detail/RoofForm.tsx | 6 +- src/hooks/useSurvey.ts | 20 +-- src/types/Survey.ts | 5 +- src/utils/common-utils.js | 23 ++-- 11 files changed, 221 insertions(+), 112 deletions(-) diff --git a/src/app/api/submission/route.ts b/src/app/api/submission/route.ts index 8e1acd1..00b06aa 100644 --- a/src/app/api/submission/route.ts +++ b/src/app/api/submission/route.ts @@ -3,6 +3,7 @@ import { SubmissionService } from './service' import { HttpStatusCode } from 'axios' import { ERROR_MESSAGES } from '@/utils/common-utils' import { loggerWrapper } from '@/libs/api-wrapper' +import { ApiError } from 'next/dist/server/api-utils' /** * @api {GET} /api/submission 제출 대상 조회 @@ -50,8 +51,11 @@ async function getSubmitTargetData(request: NextRequest): Promise } const submissionService = new SubmissionService(storeId, role) - const data = await submissionService.tryFunction(() => submissionService.getSubmissionTarget()) - return NextResponse.json(data) + const result = await submissionService.tryFunction(() => submissionService.getSubmissionTarget()) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) + } + return NextResponse.json(result) } export const GET = loggerWrapper(getSubmitTargetData) diff --git a/src/app/api/submission/service.ts b/src/app/api/submission/service.ts index 12ccbb8..431436c 100644 --- a/src/app/api/submission/service.ts +++ b/src/app/api/submission/service.ts @@ -2,7 +2,8 @@ import { prisma } from '@/libs/prisma' import { ERROR_MESSAGES } from '@/utils/common-utils' import { SubmitTargetResponse } from '@/types/Survey' import { HttpStatusCode } from 'axios' -import { NextResponse } from 'next/server' +import { ApiError } from 'next/dist/server/api-utils' +import { Prisma } from '@prisma/client' export class SubmissionService { private storeId: string @@ -18,18 +19,26 @@ export class SubmissionService { this.role = role } - async getSubmissionTarget(): Promise { + /** + * @description 제출 대상 조회 + * @returns {Promise} 제출 대상 데이터 + */ + async getSubmissionTarget(): Promise { switch (this.role) { case 'Admin_Sub': return this.getSubmissionTargetAdminSub() case 'Builder': return this.getSubmissionTargetBuilder() default: - return null + return new ApiError(HttpStatusCode.BadRequest, ERROR_MESSAGES.BAD_REQUEST) } } - private async getSubmissionTargetAdminSub(): Promise { + /** + * @description 2차점의 매핑 된 제출 대상 판매점 조회 (Admin_Sub - Musubi) + * @returns {Promise} 제출 대상 데이터 + */ + private async getSubmissionTargetAdminSub(): Promise { const query = ` SELECT MCS.STORE_ID AS targetStoreId @@ -51,7 +60,11 @@ export class SubmissionService { return await prisma.$queryRawUnsafe(this.BASE_QUERY + query, this.storeId) } - private async getSubmissionTargetBuilder(): Promise { + /** + * @description 2차점 시공권한 user의 매핑 된 제출 대상 판매점 조회 (Builder - Musubi) + * @returns {Promise} 제출 대상 데이터 + */ + private async getSubmissionTargetBuilder(): Promise { const query = ` SELECT MCAS.AGENCY_STORE_ID AS targetStoreId @@ -74,12 +87,31 @@ export class SubmissionService { return await prisma.$queryRawUnsafe(this.BASE_QUERY + query, this.storeId) } - handleRouteError(error: unknown): NextResponse { + /** + * @description API ROUTE 에러 처리 + * @param error 에러 객체 + * @returns {ApiError} 에러 객체 + */ + handleRouteError(error: any): ApiError { console.error('❌ API ROUTE ERROR : ', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + if ( + error instanceof Prisma.PrismaClientInitializationError || + error instanceof Prisma.PrismaClientUnknownRequestError || + error instanceof Prisma.PrismaClientRustPanicError || + error instanceof Prisma.PrismaClientValidationError || + error instanceof Prisma.PrismaClientUnknownRequestError + ) { + return new ApiError(HttpStatusCode.InternalServerError, ERROR_MESSAGES.PRISMA_ERROR) + } + return new ApiError(error.statusCode ?? HttpStatusCode.InternalServerError, error.message ?? ERROR_MESSAGES.FETCH_ERROR) } - async tryFunction(func: () => Promise): Promise { + /** + * @description 비동기 함수 try-catch 처리 함수 + * @param func 비동기 함수 + * @returns {Promise} 에러 객체 또는 함수 결과 + */ + async tryFunction(func: () => Promise): Promise { try { return await func() } catch (error) { diff --git a/src/app/api/survey-sales/[id]/route.ts b/src/app/api/survey-sales/[id]/route.ts index 29e1635..9b33d86 100644 --- a/src/app/api/survey-sales/[id]/route.ts +++ b/src/app/api/survey-sales/[id]/route.ts @@ -4,9 +4,9 @@ import { sessionOptions } from '@/libs/session' import { cookies } from 'next/headers' import type { SessionData } from '@/types/Auth' import { HttpStatusCode } from 'axios' -import { ERROR_MESSAGES } from '@/utils/common-utils' import { loggerWrapper } from '@/libs/api-wrapper' import { SurveySalesService } from '../service' +import { ApiError } from 'next/dist/server/api-utils' /** * @api {GET} /api/survey-sales/:id 조사 매물 조회 API @@ -51,22 +51,12 @@ async function getSurveySaleDetail(request: NextRequest): Promise const { searchParams } = new URL(request.url) const isPdf = searchParams.get('isPdf') === 'true' - const service = new SurveySalesService({}) - const survey = await service.tryFunction(() => service.fetchSurvey(Number(id))) - - if (!survey) { - return NextResponse.json({ error: ERROR_MESSAGES.NOT_FOUND }, { status: HttpStatusCode.NotFound }) + const service = new SurveySalesService({}, session) + const result = await service.tryFunction(() => service.fetchSurvey(Number(id), isPdf)) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) } - - if (isPdf || service.checkRole(survey, session)) { - return NextResponse.json(survey) - } - - if (!session?.isLoggedIn || session?.role === null) { - return NextResponse.json({ error: ERROR_MESSAGES.UNAUTHORIZED }, { status: HttpStatusCode.Unauthorized }) - } - - return NextResponse.json({ error: ERROR_MESSAGES.NO_PERMISSION }, { status: HttpStatusCode.Forbidden }) + return NextResponse.json(result) } /** @@ -107,8 +97,11 @@ async function updateSurveySaleDetail(request: NextRequest): Promise service.updateSurvey(Number(id), body.survey, body.isTemporary, body.storeId, body.role)) - return NextResponse.json(survey) + const result = await service.tryFunction(() => service.updateSurvey(Number(id), body.survey, body.isTemporary, body.storeId, body.role)) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) + } + return NextResponse.json(result) } /** @@ -132,7 +125,10 @@ async function updateSurveySaleDetail(request: NextRequest): Promise { const id = request.nextUrl.pathname.split('/').pop() ?? '' const service = new SurveySalesService({}) - await service.tryFunction(() => service.deleteSurvey(Number(id))) + const result = await service.tryFunction(() => service.deleteSurvey(Number(id))) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) + } return NextResponse.json({ status: HttpStatusCode.Ok }) } @@ -176,8 +172,11 @@ async function submitSurveySaleDetail(request: NextRequest): Promise service.submitSurvey(Number(id), body.targetId, body.targetNm)) - return NextResponse.json({ status: HttpStatusCode.Ok, data: survey }) + const result = await service.tryFunction(() => service.submitSurvey(Number(id), body.targetId, body.targetNm)) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) + } + return NextResponse.json({ status: HttpStatusCode.Ok, data: result }) } export const GET = loggerWrapper(getSurveySaleDetail) diff --git a/src/app/api/survey-sales/route.ts b/src/app/api/survey-sales/route.ts index 783d07f..c0acf3e 100644 --- a/src/app/api/survey-sales/route.ts +++ b/src/app/api/survey-sales/route.ts @@ -1,9 +1,12 @@ import { NextResponse } from 'next/server' -import { ERROR_MESSAGES } from '@/utils/common-utils' import { loggerWrapper } from '@/libs/api-wrapper' -import { HttpStatusCode } from 'axios' import { SurveySearchParams } from '@/types/Survey' import { SurveySalesService } from './service' +import { ApiError } from 'next/dist/server/api-utils' +import { getIronSession } from 'iron-session' +import { sessionOptions } from '@/libs/session' +import { cookies } from 'next/headers' +import { SessionData } from '@/types/Auth' /** * @api {GET} /api/survey-sales 설문 목록 조회 API @@ -48,7 +51,9 @@ import { SurveySalesService } from './service' * */ async function getSurveySales(request: Request) { - /** URL 파라미터 파싱 */ + const cookieStore = await cookies() + const session = await getIronSession(cookieStore, sessionOptions) + const { searchParams } = new URL(request.url) const params: SurveySearchParams = { keyword: searchParams.get('keyword'), @@ -56,19 +61,13 @@ async function getSurveySales(request: Request) { isMySurvey: searchParams.get('isMySurvey'), sort: searchParams.get('sort'), offset: searchParams.get('offset'), - role: searchParams.get('role'), - storeId: searchParams.get('storeId'), - builderId: searchParams.get('builderId'), } - const surveySalesService = new SurveySalesService(params) + const surveySalesService = new SurveySalesService(params, session) - /** 세션 체크 결과 처리 */ - const sessionCheckResult = surveySalesService.checkSession() - if (sessionCheckResult) { - return sessionCheckResult + const result = await surveySalesService.tryFunction(() => surveySalesService.getSurveySales()) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) } - const where = surveySalesService.createFilterSurvey() - const result = await surveySalesService.tryFunction(() => surveySalesService.getSurveySales(where)) return NextResponse.json({ data: result }) } @@ -112,6 +111,9 @@ async function createSurveySales(request: Request) { const body = await request.json() const surveySalesService = new SurveySalesService({}) const result = await surveySalesService.tryFunction(() => surveySalesService.createSurvey(body.survey, body.role, body.storeId)) + if (result instanceof ApiError) { + return NextResponse.json({ error: result.message }, { status: result.statusCode }) + } return NextResponse.json(result) } diff --git a/src/app/api/survey-sales/service.ts b/src/app/api/survey-sales/service.ts index 0e60556..8b75fb0 100644 --- a/src/app/api/survey-sales/service.ts +++ b/src/app/api/survey-sales/service.ts @@ -1,10 +1,10 @@ import { prisma } from '@/libs/prisma' import { SurveyBasicInfo, SurveyRegistRequest, SurveySearchParams } from '@/types/Survey' import { convertToSnakeCase, ERROR_MESSAGES } from '@/utils/common-utils' -import { NextResponse } from 'next/server' import { Prisma } from '@prisma/client' import type { SessionData } from '@/types/Auth' import { HttpStatusCode } from 'axios' +import { ApiError } from 'next/dist/server/api-utils' type WhereCondition = { AND: any[] @@ -36,13 +36,15 @@ const ITEMS_PER_PAGE = 10 */ export class SurveySalesService { private params!: SurveySearchParams + private session?: SessionData /** * @description 생성자 * @param {SurveySearchParams} params 검색 파라미터 */ - constructor(params: SurveySearchParams) { + constructor(params: SurveySearchParams, session?: SessionData) { this.params = params + this.session = session } /** @@ -50,17 +52,17 @@ export class SurveySalesService { * @param {SearchParams} params 검색 파라미터 * @returns {NextResponse} 세션 체크 결과 */ - checkSession() { - if (this.params.role === null) { - return NextResponse.json({ data: [], count: 0 }) + checkSession(): ApiError | null { + if (!this.session?.isLoggedIn || this.session?.role === null) { + return new ApiError(HttpStatusCode.Unauthorized, ERROR_MESSAGES.UNAUTHORIZED) } - if (this.params.role === 'Builder' || this.params.role === 'Partner') { + if (this.session?.role === 'Builder' || this.session?.role === 'Partner') { if (this.params.builderId === null) { - return NextResponse.json({ data: [], count: 0 }) + return new ApiError(HttpStatusCode.Forbidden, ERROR_MESSAGES.NO_PERMISSION) } } else { - if (this.params.storeId === null) { - return NextResponse.json({ data: [], count: 0 }) + if (this.session?.storeId === null) { + return new ApiError(HttpStatusCode.Forbidden, ERROR_MESSAGES.NO_PERMISSION) } } return null @@ -107,19 +109,21 @@ export class SurveySalesService { private createRoleCondition(): WhereCondition { const where: WhereCondition = { AND: [] } - switch (this.params.role) { + switch (this.session?.role) { case 'Admin': where.OR = [ - { AND: [{ STORE_ID: { equals: this.params.storeId } }] }, - { AND: [{ SUBMISSION_TARGET_ID: { equals: this.params.storeId } }, { SUBMISSION_STATUS: { equals: true } }] }, + { AND: [{ STORE_ID: { equals: this.session?.storeId } }] }, + { AND: [{ SUBMISSION_TARGET_ID: { equals: this.session?.storeId } }, { SUBMISSION_STATUS: { equals: true } }] }, ] break case 'Admin_Sub': where.OR = [ - { AND: [{ STORE_ID: { equals: this.params.storeId } }, { CONSTRUCTION_POINT_ID: { equals: this.params.builderId } }] }, + { + AND: [{ STORE_ID: { equals: this.session?.storeId } }, { CONSTRUCTION_POINT_ID: { equals: this.session?.builderId } }], + }, { AND: [ - { SUBMISSION_TARGET_ID: { equals: this.params.storeId } }, + { SUBMISSION_TARGET_ID: { equals: this.session?.storeId } }, { CONSTRUCTION_POINT_ID: { not: null } }, { CONSTRUCTION_POINT_ID: { not: '' } }, { SUBMISSION_STATUS: { equals: true } }, @@ -129,10 +133,10 @@ export class SurveySalesService { break case 'Builder': case 'Partner': - where.AND.push({ CONSTRUCTION_POINT_ID: { equals: this.params.builderId } }) + where.AND.push({ CONSTRUCTION_POINT_ID: { equals: this.session?.builderId } }) break case 'T01': - where.OR = [{ NOT: { SRL_NO: { startsWith: '一時保存' } } }, { STORE_ID: { equals: this.params.storeId } }] + where.OR = [{ NOT: { SRL_NO: { startsWith: '一時保存' } } }, { STORE_ID: { equals: this.session?.storeId } }] break } return where @@ -162,7 +166,6 @@ export class SurveySalesService { if (Object.keys(roleCondition).length > 0) { where.AND.push(roleCondition) } - return where } @@ -171,7 +174,13 @@ export class SurveySalesService { * @param {WhereCondition} where 조사 매물 검색 조건 * @returns {Promise<{ data: SurveyBasicInfo[], count: number }>} 조사 매물 데이터 */ - async getSurveySales(where: WhereCondition) { + async getSurveySales(): Promise<{ data: SurveyBasicInfo[]; count: number } | ApiError> { + const sessionCheckResult = this.checkSession() + if (sessionCheckResult) { + return sessionCheckResult + } + + const where = this.createFilterSurvey() /** 조사 매물 조회 */ //@ts-ignore const surveys = await prisma.SD_SURVEY_SALES_BASIC_INFO.findMany({ @@ -245,12 +254,20 @@ export class SurveySalesService { * @param {number} id 조사 매물 ID * @returns {Promise} 조사 매물 데이터 */ - async fetchSurvey(id: number) { + async fetchSurvey(id: number, isPdf: boolean): Promise { // @ts-ignore - return (await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ + const result = await prisma.SD_SURVEY_SALES_BASIC_INFO.findFirst({ where: { ID: id }, include: { DETAIL_INFO: true }, - })) as SurveyBasicInfo + }) + if (!result) { + return new ApiError(HttpStatusCode.NotFound, ERROR_MESSAGES.NOT_FOUND) + } + if (!isPdf) { + if (!this.session?.isLoggedIn) return new ApiError(HttpStatusCode.Unauthorized, ERROR_MESSAGES.UNAUTHORIZED) + if (!this.checkRole(result, this.session as SessionData)) return new ApiError(HttpStatusCode.Forbidden, ERROR_MESSAGES.NO_PERMISSION) + } + return result } /** @@ -339,7 +356,7 @@ export class SurveySalesService { * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) */ checkRole(survey: any, session: SessionData): boolean { - if (!survey || !session.isLoggedIn) return false + if (!survey || !session) return false const roleChecks = { T01: () => this.checkT01Role(survey), @@ -352,15 +369,38 @@ export class SurveySalesService { return roleChecks[session.role as keyof typeof roleChecks]?.() ?? false } + /** + * @description T01 권한 체크 + * - 임시저장 매물을 제외한 전 매물 조회 가능 + * + * @param {any} survey 조사 매물 데이터 + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ private checkT01Role(survey: any): boolean { return survey.SRL_NO !== '一時保存' } + /** + * @description Admin 권한 체크 (1차점 - Order) + * - 같은 판매점에서 작성한 매물, 제출 받은 매물 조회 가능 + * + * @param {any} survey 조사 매물 데이터 + * @param {string | null} storeId 판매점 ID + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ private checkAdminRole(survey: any, storeId: string | null): boolean { if (!storeId) return false return survey.SUBMISSION_STATUS ? survey.SUBMISSION_TARGET_ID === storeId || survey.STORE_ID === storeId : survey.STORE_ID === storeId } + /** + * @description Admin_Sub 권한 체크 (2차점 - Musubi) + * - 같은 판매점에서 작성한 매물, 시공권한 user에게 제출받은 매물 조회 가능 + * + * @param {any} survey 조사 매물 데이터 + * @param {string | null} storeId 판매점 ID + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ private checkAdminSubRole(survey: any, storeId: string | null): boolean { if (!storeId) return false return survey.SUBMISSION_STATUS @@ -368,17 +408,44 @@ export class SurveySalesService { : survey.STORE_ID === storeId && !survey.CONSTRUCTION_POINT_ID } + /** + * @description Partner 또는 Builder 권한 체크 + * - 같은 시공점에서 작성한 매물 조회 가능 + * + * @param {any} survey 조사 매물 데이터 + * @param {string | null} builderId 시공점 ID + * @returns {boolean} 해당 매물의 조회 권한 여부 (true: 권한 있음, false: 권한 없음) + */ private checkPartnerOrBuilderRole(survey: any, builderId: string | null): boolean { if (!builderId) return false return survey.CONSTRUCTION_POINT_ID === builderId } - handleRouteError(error: unknown): NextResponse { + /** + * @description API ROUTE 에러 처리 + * @param {any} error 에러 객체 + * @returns {ApiError} 에러 객체 + */ + handleRouteError(error: any): ApiError { console.error('❌ API ROUTE ERROR : ', error) - return NextResponse.json({ error: ERROR_MESSAGES.FETCH_ERROR }, { status: HttpStatusCode.InternalServerError }) + if ( + error instanceof Prisma.PrismaClientInitializationError || + error instanceof Prisma.PrismaClientUnknownRequestError || + error instanceof Prisma.PrismaClientRustPanicError || + error instanceof Prisma.PrismaClientValidationError || + error instanceof Prisma.PrismaClientUnknownRequestError + ) { + return new ApiError(HttpStatusCode.InternalServerError, ERROR_MESSAGES.PRISMA_ERROR) + } + return new ApiError(error.statusCode ?? HttpStatusCode.InternalServerError, error.message ?? ERROR_MESSAGES.FETCH_ERROR) } - async tryFunction(func: () => Promise): Promise { + /** + * @description 비동기 함수 try-catch 처리 함수 + * @param {() => Promise} func 비동기 함수 + * @returns {Promise} 에러 객체 또는 함수 결과 + */ + async tryFunction(func: () => Promise): Promise { try { return await func() } catch (error) { diff --git a/src/components/pdf/SurveySaleDownloadPdf.tsx b/src/components/pdf/SurveySaleDownloadPdf.tsx index 0030612..0999906 100644 --- a/src/components/pdf/SurveySaleDownloadPdf.tsx +++ b/src/components/pdf/SurveySaleDownloadPdf.tsx @@ -7,13 +7,14 @@ import { useSurvey } from '@/hooks/useSurvey' import { radioEtcData, roofMaterial, selectBoxOptions, supplementaryFacilities } from '../survey-sale/detail/RoofForm' import { useSpinnerStore } from '@/store/spinnerStore' import { useSessionStore } from '@/store/session' +import { SURVEY_ALERT_MSG } from '@/types/Survey' export default function SurveySaleDownloadPdf() { const params = useParams() const id = params.id const router = useRouter() - const { surveyDetail, isLoadingSurveyDetail } = useSurvey(Number(id), true) + const { surveyDetail, isLoadingSurveyDetail, showSurveyAlert } = useSurvey(Number(id), true) const { setIsShow } = useSpinnerStore() const { session } = useSessionStore() @@ -23,11 +24,6 @@ export default function SurveySaleDownloadPdf() { /** 페이지 랜더링 이후 PDF 생성 */ useEffect(() => { if (isLoadingSurveyDetail || isGeneratedRef.current) return - if (surveyDetail === null) { - alert('データが見つかりません。') - router.replace('/') - return - } isGeneratedRef.current = true handleDownPdf() }, [surveyDetail?.id, isLoadingSurveyDetail]) @@ -65,10 +61,11 @@ export default function SurveySaleDownloadPdf() { } else { router.replace('/') } - alert('PDFの生成が完了しました。 ポップアップウィンドウからダウンロードしてください。') + showSurveyAlert('PDFの生成が完了しました。 ポップアップウィンドウからダウンロードしてください。') }) .catch((error: any) => { - console.error('error', error) + console.error('❌ PDF GENERATION ERROR', error) + showSurveyAlert(SURVEY_ALERT_MSG.PDF_GENERATION_ERROR) }) } diff --git a/src/components/survey-sale/detail/ButtonForm.tsx b/src/components/survey-sale/detail/ButtonForm.tsx index 5ca80a5..c731d98 100644 --- a/src/components/survey-sale/detail/ButtonForm.tsx +++ b/src/components/survey-sale/detail/ButtonForm.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { requiredFields, useSurvey } from '@/hooks/useSurvey' import { usePopupController } from '@/store/popupController' -import { ALERT_MESSAGES } from '@/types/Survey' +import { SURVEY_ALERT_MSG } from '@/types/Survey' interface ButtonFormProps { mode: Mode @@ -117,7 +117,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { router.push(`/survey-sale/${savedId}`) } } - showSurveyAlert(ALERT_MESSAGES.TEMP_SAVE_SUCCESS) + showSurveyAlert(SURVEY_ALERT_MSG.TEMP_SAVE_SUCCESS) } /** 입력 필드 포커스 처리 */ @@ -130,7 +130,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { const saveProcess = async (emptyField: string | null, isSubmitProcess?: boolean) => { if (emptyField?.trim() === '') { if (!isSubmitProcess) { - showSurveyConfirm(ALERT_MESSAGES.SAVE_CONFIRM, async () => { + showSurveyConfirm(SURVEY_ALERT_MSG.SAVE_CONFIRM, async () => { await handleSuccessfulSave(isSubmitProcess) }) } else { @@ -155,7 +155,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { if (isSubmitProcess) { popupController.setSurveySaleSubmitPopup(true) } else { - showSurveyAlert(ALERT_MESSAGES.SAVE_SUCCESS) + showSurveyAlert(SURVEY_ALERT_MSG.SAVE_SUCCESS) } } } else { @@ -165,7 +165,7 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { await router.push(`/survey-sale/${savedId}?show=true`) } else { await router.push(`/survey-sale/${savedId}`) - showSurveyAlert(ALERT_MESSAGES.SAVE_SUCCESS) + showSurveyAlert(SURVEY_ALERT_MSG.SAVE_SUCCESS) } } } @@ -173,10 +173,10 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 필수값 미입력 처리 */ const handleFailedSave = (emptyField: string | null) => { if (emptyField?.includes('Unit')) { - showSurveyAlert(ALERT_MESSAGES.UNIT_REQUIRED) + showSurveyAlert(SURVEY_ALERT_MSG.UNIT_REQUIRED) } else { const fieldInfo = requiredFields.find((field) => field.field === emptyField) - showSurveyAlert(ALERT_MESSAGES.REQUIRED_FIELD, fieldInfo?.name || '') + showSurveyAlert(SURVEY_ALERT_MSG.REQUIRED_FIELD, fieldInfo?.name || '') } focusInput(emptyField as keyof SurveyDetailInfo) } @@ -184,10 +184,10 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 삭제 로직 */ const handleDelete = async () => { if (!Number.isNaN(id)) { - showSurveyConfirm(ALERT_MESSAGES.DELETE_CONFIRM, async () => { + showSurveyConfirm(SURVEY_ALERT_MSG.DELETE_CONFIRM, async () => { await deleteSurvey() if (!isDeletingSurvey) { - showSurveyAlert(ALERT_MESSAGES.DELETE_SUCCESS) + showSurveyAlert(SURVEY_ALERT_MSG.DELETE_SUCCESS) router.push('/survey-sale') } }) @@ -197,16 +197,16 @@ export default function ButtonForm({ mode, setMode, data }: ButtonFormProps) { /** 제출 로직 */ const handleSubmit = async () => { if (data.basic.srlNo?.startsWith('一時保存') && Number.isNaN(id)) { - showSurveyAlert(ALERT_MESSAGES.TEMP_SAVE_SUBMIT_ERROR) + showSurveyAlert(SURVEY_ALERT_MSG.TEMP_SAVE_SUBMIT_ERROR) return } if (mode === 'READ') { - showSurveyConfirm(ALERT_MESSAGES.SUBMIT_CONFIRM, async () => { + showSurveyConfirm(SURVEY_ALERT_MSG.SUBMIT_CONFIRM, async () => { popupController.setSurveySaleSubmitPopup(true) }) } else { - showSurveyConfirm(ALERT_MESSAGES.SAVE_AND_SUBMIT_CONFIRM, async () => { + showSurveyConfirm(SURVEY_ALERT_MSG.SAVE_AND_SUBMIT_CONFIRM, async () => { handleSave(false, true) }) } diff --git a/src/components/survey-sale/detail/RoofForm.tsx b/src/components/survey-sale/detail/RoofForm.tsx index 36527ae..35d7828 100644 --- a/src/components/survey-sale/detail/RoofForm.tsx +++ b/src/components/survey-sale/detail/RoofForm.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import type { Mode, SurveyDetailInfo, SurveyDetailRequest } from '@/types/Survey' import { useSurvey } from '@/hooks/useSurvey' -import { ALERT_MESSAGES } from '@/types/Survey' +import { SURVEY_ALERT_MSG } from '@/types/Survey' type RadioEtcKeys = | 'structureOrder' @@ -755,7 +755,7 @@ const MultiCheck = ({ if (isRoofMaterial) { const totalSelected = selectedValues.length + (isOtherSelected || isOtherCheck ? 1 : 0) if (totalSelected >= 2) { - showSurveyAlert(ALERT_MESSAGES.ROOF_MATERIAL_MAX_SELECT_ERROR) + showSurveyAlert(SURVEY_ALERT_MSG.ROOF_MATERIAL_MAX_SELECT_ERROR) return } } @@ -769,7 +769,7 @@ const MultiCheck = ({ if (isRoofMaterial) { const currentSelected = selectedValues.length if (!isOtherCheck && currentSelected >= 2) { - showSurveyAlert(ALERT_MESSAGES.ROOF_MATERIAL_MAX_SELECT_ERROR) + showSurveyAlert(SURVEY_ALERT_MSG.ROOF_MATERIAL_MAX_SELECT_ERROR) return } } diff --git a/src/hooks/useSurvey.ts b/src/hooks/useSurvey.ts index 345d4a4..98821a1 100644 --- a/src/hooks/useSurvey.ts +++ b/src/hooks/useSurvey.ts @@ -1,4 +1,4 @@ -import { ALERT_MESSAGES, type SubmitTargetResponse, type SurveyBasicInfo, type SurveyDetailRequest, type SurveyRegistRequest } from '@/types/Survey' +import { SURVEY_ALERT_MSG, type SubmitTargetResponse, type SurveyBasicInfo, type SurveyDetailRequest, type SurveyRegistRequest } from '@/types/Survey' import { useMemo } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useSurveyFilterStore } from '@/store/surveyFilterStore' @@ -7,6 +7,8 @@ import { useAxios } from './useAxios' import { queryStringFormatter } from '@/utils/common-utils' import { useRouter } from 'next/navigation' + + export const requiredFields = [ { field: 'installationSystem', @@ -93,7 +95,7 @@ export function useSurvey( refetchSurveyList: () => void refetchSurveyDetail: () => void getSubmitTarget: (params: { storeId: string; role: string }) => Promise - showSurveyAlert: (message: (typeof ALERT_MESSAGES)[keyof typeof ALERT_MESSAGES] | string, requiredField?: string) => void + showSurveyAlert: (message: (typeof SURVEY_ALERT_MSG)[keyof typeof SURVEY_ALERT_MSG] | string, requiredField?: string) => void showSurveyConfirm: (message: string, onConfirm: () => void, onCancel?: () => void) => void } { const queryClient = useQueryClient() @@ -106,7 +108,8 @@ export function useSurvey( * @description 조사 매물 목록, 상세 데이터 조회 에러 처리 * * @param {any} error 에러 객체 - * @returns {void} 라우팅 처리 + * @param {boolean} isThrow 에러 Throw 처리 여부 + * @returns {void} 라우팅 처리 / 에러 Throw 처리 */ const handleError = (error: any, isThrow?: boolean) => { const status = error.response?.status @@ -175,7 +178,7 @@ export function useSurvey( isLoading: isLoadingSurveyList, refetch: refetchSurveyList, } = useQuery({ - queryKey: ['survey', 'list', keyword, searchOption, isMySurvey, sort, offset, session?.storeNm, session?.builderId, session?.role], + queryKey: ['survey', 'list', keyword, searchOption, isMySurvey, sort, offset], queryFn: async () => { return await tryFunction( () => @@ -186,15 +189,13 @@ export function useSurvey( isMySurvey, sort, offset, - storeId: session?.storeId, - builderId: session?.builderId, - role: session?.role, }, }), true, false, ) }, + enabled: !isPdf, }) /** @@ -436,7 +437,10 @@ export function useSurvey( * @param {string} message 알림 메시지 * @param {string} [requiredField] 필수 필드 이름 */ - const showSurveyAlert = (message: (typeof ALERT_MESSAGES)[keyof typeof ALERT_MESSAGES] | string, requiredField?: (typeof requiredFields)[number]['field']) => { + const showSurveyAlert = ( + message: (typeof SURVEY_ALERT_MSG)[keyof typeof SURVEY_ALERT_MSG] | string, + requiredField?: (typeof requiredFields)[number]['field'], + ) => { if (requiredField) { alert(`${requiredField} ${message}`) } else { diff --git a/src/types/Survey.ts b/src/types/Survey.ts index c2d80b6..3802916 100644 --- a/src/types/Survey.ts +++ b/src/types/Survey.ts @@ -325,7 +325,7 @@ export type SurveySearchParams = { } -export const ALERT_MESSAGES = { +export const SURVEY_ALERT_MSG = { /** 기본 메세지 */ /** 저장 성공 - "저장되었습니다." */ SAVE_SUCCESS: '保存されました。', @@ -351,4 +351,7 @@ export const ALERT_MESSAGES = { REQUIRED_FIELD: '項目が空です。', /** 최대 선택 오류 메세지 - "지붕재는 최대 2개까지 선택할 수 있습니다." */ ROOF_MATERIAL_MAX_SELECT_ERROR: '屋根材は最大2個まで選択できます。', + + /** PDF 생성 오류 - "PDF 생성에 실패했습니다." */ + PDF_GENERATION_ERROR: 'PDF 生成に失敗しました。', } \ No newline at end of file diff --git a/src/utils/common-utils.js b/src/utils/common-utils.js index 51be0e6..e4eb144 100644 --- a/src/utils/common-utils.js +++ b/src/utils/common-utils.js @@ -155,7 +155,7 @@ export const unescapeString = (str) => { */ while (regex.test(str)) { - str = str.replace(regex, (matched) => chars[matched] || matched); + str = str.replace(regex, (matched) => chars[matched] || matched) } return str } @@ -186,29 +186,28 @@ function isObject(value) { return value !== null && typeof value === 'object' } - // 카멜케이스를 스네이크케이스로 변환하는 함수 export const toSnakeCase = (str) => { - return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) } // 객체의 키를 스네이크케이스로 변환하는 함수 export const convertToSnakeCase = (obj) => { - if (obj === null || obj === undefined) return obj; - + if (obj === null || obj === undefined) return obj + if (Array.isArray(obj)) { return obj.map((item) => convertToSnakeCase(item)) } if (typeof obj === 'object') { return Object.keys(obj).reduce((acc, key) => { - const snakeKey = toSnakeCase(key).toUpperCase(); - acc[snakeKey] = convertToSnakeCase(obj[key]); - return acc; - }, {}); + const snakeKey = toSnakeCase(key).toUpperCase() + acc[snakeKey] = convertToSnakeCase(obj[key]) + return acc + }, {}) } - - return obj; + + return obj } /** @@ -225,4 +224,6 @@ export const ERROR_MESSAGES = { FETCH_ERROR: 'データの取得に失敗しました。', /** 잘못된 요청입니다. */ BAD_REQUEST: '間違ったリクエストです。', + /** 데이터베이스 오류가 발생했습니다. */ + PRISMA_ERROR: 'データベース エラーが発生しました。', }