Merge pull request 'feature/qna' (#90) from feature/qna into dev
Reviewed-on: #90
This commit is contained in:
commit
bc6a83705b
@ -1,12 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { loggerWrapper } from '@/libs/api-wrapper'
|
||||
import { prisma } from '@/libs/prisma'
|
||||
import type { CommCode } from '@/types/CommCode'
|
||||
import { CommonCode } from '@/types/Inquiry'
|
||||
|
||||
async function getCommCode(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const headCode = searchParams.get('headCode')
|
||||
if (headCode === 'QNA_CD') {
|
||||
return getQnaCd()
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const headCd = await prisma.BC_COMM_H.findFirst({
|
||||
@ -24,23 +27,22 @@ async function getCommCode(request: NextRequest): Promise<NextResponse> {
|
||||
|
||||
if (headCode === 'SALES_OFFICE_CD') {
|
||||
return getSaleOffice(headCd.HEAD_CD)
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const roofMaterials: CommCode[] = await prisma.BC_COMM_L.findMany({
|
||||
where: {
|
||||
HEAD_CD: headCd.HEAD_CD,
|
||||
},
|
||||
select: {
|
||||
HEAD_CD: true,
|
||||
CODE: true,
|
||||
CODE_JP: true,
|
||||
},
|
||||
orderBy: {
|
||||
CODE: 'asc',
|
||||
},
|
||||
})
|
||||
return NextResponse.json(roofMaterials)
|
||||
}
|
||||
// @ts-ignore
|
||||
const roofMaterials: CommCode[] = await prisma.BC_COMM_L.findMany({
|
||||
where: {
|
||||
HEAD_CD: headCd.HEAD_CD,
|
||||
},
|
||||
select: {
|
||||
HEAD_CD: true,
|
||||
CODE: true,
|
||||
CODE_JP: true,
|
||||
},
|
||||
orderBy: {
|
||||
CODE: 'asc',
|
||||
},
|
||||
})
|
||||
return NextResponse.json(roofMaterials)
|
||||
} catch (error) {
|
||||
console.error('❌ 데이터 조회 중 오류가 발생했습니다:', error)
|
||||
return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 })
|
||||
@ -64,4 +66,50 @@ const getSaleOffice = async (headCode: string) => {
|
||||
return NextResponse.json(commCodeSaleOffice)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description QNA 공통 코드 조회
|
||||
* @returns {CommonCode[]} QNA 공통 코드 목록
|
||||
*/
|
||||
const getQnaCd = async () => {
|
||||
// @ts-ignore
|
||||
const headCdList: { HEAD_CD: string; HEAD_ID: string }[] = await prisma.BC_COMM_H.findMany({
|
||||
where: {
|
||||
OR: [{ HEAD_ID: { in: ['QNA_CLS_LRG_CD'] } }, { HEAD_ID: { in: ['QNA_CLS_MID_CD'] } }, { HEAD_ID: { in: ['QNA_CLS_SML_CD'] } }],
|
||||
},
|
||||
select: {
|
||||
HEAD_CD: true,
|
||||
HEAD_ID: true,
|
||||
},
|
||||
})
|
||||
const result: CommonCode[] = []
|
||||
// @ts-ignore
|
||||
const commCodeQna: CommCode[] = await prisma.BC_COMM_L.findMany({
|
||||
where: {
|
||||
HEAD_CD: {
|
||||
in: headCdList.map((item) => item.HEAD_CD).filter(Boolean),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
HEAD_CD: true,
|
||||
CODE: true,
|
||||
CODE_JP: true,
|
||||
REF_CHR1: true,
|
||||
},
|
||||
})
|
||||
commCodeQna.forEach((item) => {
|
||||
result.push({
|
||||
// @ts-ignore
|
||||
headCd: item.HEAD_CD,
|
||||
// @ts-ignore
|
||||
headId: headCdList.find((headCd) => headCd.HEAD_CD === item.HEAD_CD)?.HEAD_ID ?? '',
|
||||
// @ts-ignore
|
||||
code: item.CODE,
|
||||
// @ts-ignore
|
||||
name: item.CODE_JP,
|
||||
// @ts-ignore
|
||||
refChar1: item.REF_CHR1 ?? '',
|
||||
})
|
||||
})
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
export const GET = loggerWrapper(getCommCode)
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import axios from 'axios'
|
||||
import { loggerWrapper } from '@/libs/api-wrapper'
|
||||
import { QnaService } from './service'
|
||||
import { ApiError } from 'next/dist/server/api-utils'
|
||||
|
||||
/**
|
||||
* @api {GET} /api/qna 문의 유형 목록 조회 API
|
||||
* @apiName GET /api/qna
|
||||
* @apiGroup Qna
|
||||
* @apiDescription 문의 유형 목록 조회 API
|
||||
*
|
||||
* @apiSuccess {Object} data 문의 유형 목록
|
||||
* @apiSuccess {String} data.headCd 문의 유형 헤드 코드
|
||||
* @apiSuccess {String} data.code 문의 유형 코드
|
||||
* @apiSuccess {String} data.codeJp 문의 유형 이름 - 일본어
|
||||
* @apiSuccess {String} data.refChr1 문의 유형 참조 - 유형 상위 구분
|
||||
*
|
||||
* @apiExample {curl} Example usage:
|
||||
* curl -X GET http://localhost:3000/api/qna
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response:
|
||||
* {
|
||||
* "data": [
|
||||
* {
|
||||
* "headCd": "204200",
|
||||
* "code": "1",
|
||||
* "codeJp": "1",
|
||||
* "refChr1": "1"
|
||||
* }
|
||||
* ],
|
||||
* ...
|
||||
* }
|
||||
*/
|
||||
async function getCommonCodeListData(): Promise<NextResponse> {
|
||||
const service = new QnaService()
|
||||
const response = await service.tryFunction(() => axios.get(`${process.env.NEXT_PUBLIC_INQUIRY_API_URL}/api/system/commonCodeListData`))
|
||||
if (response instanceof ApiError) {
|
||||
return NextResponse.json({ error: response.message }, { status: response.statusCode })
|
||||
}
|
||||
const result = service.getInquiryTypeList(response.data.apiCommCdList)
|
||||
return NextResponse.json({ data: result })
|
||||
}
|
||||
|
||||
export const GET = loggerWrapper(getCommonCodeListData)
|
||||
@ -1,4 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import axios, { HttpStatusCode } from 'axios'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { loggerWrapper } from '@/libs/api-wrapper'
|
||||
import { QnaService } from '../service'
|
||||
@ -12,9 +12,9 @@ import { cookies } from 'next/headers'
|
||||
* @api {POST} /api/qna/save 문의 저장 API
|
||||
* @apiName POST /api/qna/save
|
||||
* @apiGroup Qna
|
||||
* @apiDescription 문의 저장 API
|
||||
* @apiDescription 문의 메일 발송 후 문의 저장 API
|
||||
*
|
||||
* @apiBody {InquiryRequest} inquiryRequest 문의 저장 요청 파라미터
|
||||
* @apiBody {FormData} formData 문의 저장 요청 파라미터
|
||||
*
|
||||
* @apiExample {curl} Example usage:
|
||||
* curl -X POST http://localhost:3000/api/qna/save
|
||||
@ -32,9 +32,14 @@ import { cookies } from 'next/headers'
|
||||
async function setQna(request: Request): Promise<NextResponse> {
|
||||
const cookieStore = await cookies()
|
||||
const session = await getIronSession<SessionData>(cookieStore, sessionOptions)
|
||||
|
||||
const service = new QnaService(session)
|
||||
|
||||
const formData = await request.formData()
|
||||
const mailResult = await service.sendMail(formData)
|
||||
if (mailResult instanceof ApiError) {
|
||||
return NextResponse.json({ error: mailResult.message }, { status: mailResult.statusCode })
|
||||
}
|
||||
|
||||
const result = await service.tryFunction(() =>
|
||||
axios.post(`${process.env.NEXT_PUBLIC_INQUIRY_API_URL}/api/qna/save`, formData, {
|
||||
headers: {
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { SessionData } from '@/types/Auth'
|
||||
import { CommonCode } from '@/types/Inquiry'
|
||||
import { CommonCode, InquiryRequest } from '@/types/Inquiry'
|
||||
import { ERROR_MESSAGE } from '@/hooks/useAlertMsg'
|
||||
import { HttpStatusCode } from 'axios'
|
||||
import { ApiError } from 'next/dist/server/api-utils'
|
||||
import { prisma } from '@/libs/prisma'
|
||||
import { sendEmail } from '@/libs/mailer'
|
||||
|
||||
export class QnaService {
|
||||
private session?: SessionData
|
||||
@ -15,6 +17,7 @@ export class QnaService {
|
||||
* @returns {ApiError} 에러 객체
|
||||
*/
|
||||
private handleRouteError(error: any): ApiError {
|
||||
if (error === undefined) return new ApiError(HttpStatusCode.InternalServerError, ERROR_MESSAGE.SERVER_ERROR)
|
||||
console.error('❌ API ROUTE ERROR : ', error)
|
||||
return new ApiError(error.response.status ?? HttpStatusCode.InternalServerError, error.response.data.result.message ?? ERROR_MESSAGE.FETCH_ERROR)
|
||||
}
|
||||
@ -23,13 +26,13 @@ export class QnaService {
|
||||
* @param {() => Promise<any>} func 비동기 함수
|
||||
* @returns {Promise<ApiError | any>} 에러 객체 또는 함수 결과
|
||||
*/
|
||||
async tryFunction(func: () => Promise<any>, isFile?: boolean): Promise<ApiError | any> {
|
||||
async tryFunction(func: () => Promise<any>, shouldThrowResult?: boolean): Promise<ApiError | any> {
|
||||
if (this.session !== undefined && !this.session?.isLoggedIn) {
|
||||
return new ApiError(HttpStatusCode.Unauthorized, ERROR_MESSAGE.UNAUTHORIZED)
|
||||
}
|
||||
try {
|
||||
const response = await func()
|
||||
if (isFile) return response
|
||||
if (shouldThrowResult) return response
|
||||
return this.handleResult(response)
|
||||
} catch (error) {
|
||||
return this.handleRouteError(error)
|
||||
@ -54,12 +57,22 @@ export class QnaService {
|
||||
* @param {string[]} responseList 문의 유형 타입 목록
|
||||
* @returns {CommonCode[]} 문의 유형 타입 목록
|
||||
*/
|
||||
getInquiryTypeList(responseList: string[]): CommonCode[] {
|
||||
getInquiryTypeList(responseList: any): CommonCode[] {
|
||||
const codeList: CommonCode[] = []
|
||||
responseList.forEach((item: any) => {
|
||||
if (item.headCd === '204200' || item.headCd === '204300' || item.headCd === '204400') {
|
||||
const headCdList: { headCd: string; headId: string }[] = []
|
||||
responseList.apiHeadCdList.forEach((item: any) => {
|
||||
if (item.headId === 'QNA_CLS_LRG_CD' || item.headId === 'QNA_CLS_MID_CD' || item.headId === 'QNA_CLS_SML_CD') {
|
||||
headCdList.push({
|
||||
headCd: item.headCd,
|
||||
headId: item.headId,
|
||||
})
|
||||
}
|
||||
})
|
||||
responseList.apiCommCdList.forEach((item: any) => {
|
||||
if (headCdList.some((headCd) => headCd.headCd === item.headCd)) {
|
||||
codeList.push({
|
||||
headCd: item.headCd,
|
||||
headId: headCdList.find((headCd) => headCd.headCd === item.headCd)?.headId ?? '',
|
||||
code: item.code,
|
||||
name: item.codeJp,
|
||||
refChar1: item.refChr1,
|
||||
@ -86,4 +99,94 @@ export class QnaService {
|
||||
})
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 문의 메일 발송
|
||||
* @param {FormData} formData 문의 메일 발송 요청 파라미터 - qnaClsLrgCd, title, contents, files
|
||||
* @returns {ApiError | null} 에러 객체 또는 null
|
||||
*/
|
||||
async sendMail(formData: FormData): Promise<ApiError | void> {
|
||||
const receivers: string[] = await this.getReceiver(formData.get('qnaClsLrgCd') as string)
|
||||
if (receivers.length === 0) {
|
||||
return new ApiError(HttpStatusCode.InternalServerError, ERROR_MESSAGE.EMAIL_SEND_ERROR)
|
||||
}
|
||||
const files = formData.getAll('files') as File[]
|
||||
const fileBuffers = await Promise.all(files.map((file) => file.arrayBuffer()))
|
||||
|
||||
const attachments = files.map((file, index) => ({
|
||||
filename: file.name,
|
||||
content: Buffer.from(fileBuffers[index]),
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
}))
|
||||
|
||||
return this.tryFunction(() => {
|
||||
return sendEmail({
|
||||
from: 'test@test.com',
|
||||
to: receivers,
|
||||
subject: `[HANASYS お問い合わせ] ${formData.get('title')}`,
|
||||
content: this.generateEmailContent(formData),
|
||||
attachments: attachments,
|
||||
})
|
||||
}, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 문의 수신자 조회
|
||||
* @param {string} qnaClsLrgCd 문의 유형 대분류 코드
|
||||
* @returns {string[]} 문의 수신자 목록
|
||||
*/
|
||||
async getReceiver(qnaClsLrgCd: string): Promise<string[]> {
|
||||
const query = `
|
||||
OPEN SYMMETRIC KEY SYMMETRICKEY DECRYPTION BY CERTIFICATE CERTI_QSPJP;
|
||||
SELECT CONVERT(NVARCHAR(100), DecryptByKey(bu.e_mail)) AS email
|
||||
FROM BC_USER bu
|
||||
WHERE bu.user_id IN (
|
||||
SELECT user_id
|
||||
FROM SY_POLICY_U spu
|
||||
WHERE policy_cd = (
|
||||
SELECT bcl.ref_chr2
|
||||
FROM BC_COMM_L bcl
|
||||
WHERE bcl.head_cd = (SELECT head_cd FROM BC_COMM_H bch WHERE head_id = 'QNA_CLS_MID_CD')
|
||||
AND bcl.ref_chr1 = '${qnaClsLrgCd}'
|
||||
GROUP BY bcl.ref_chr2
|
||||
)
|
||||
)
|
||||
CLOSE SYMMETRIC KEY SYMMETRICKEY;`
|
||||
|
||||
const receivers: { email: string }[] = await prisma.$queryRawUnsafe(query)
|
||||
return receivers.map((receiver) => receiver.email)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 문의 이메일 내용 생성
|
||||
* @param {FormData} formData 문의 메일 발송 요청 파라미터 - qnaClsLrgCd, title, contents, files
|
||||
* @returns {string} 문의 이메일 내용
|
||||
*/
|
||||
generateEmailContent = (formData: FormData) => `
|
||||
<div>
|
||||
<p style="font-size: 13px; font-weight: 400; color: #2e3a59; margin-bottom: 15px;">
|
||||
お問い合わせが登録されました。<br/>
|
||||
{ QSP > System mgt. > お問い合わせ } でお問い合わせ内容を確認してください。
|
||||
</p>
|
||||
<p style="font-size: 13px; font-weight: 400; color: #2e3a59; margin-bottom: 3px;">
|
||||
-登録者: <span style="color: #417DDC;">${formData.get('regUserNm')}</span>
|
||||
</p>
|
||||
<p style="font-size: 13px; font-weight: 400; color: #2e3a59; margin-bottom: 3px;">
|
||||
-販売店ID:
|
||||
<span style="color: #417DDC;">
|
||||
${formData.get('storeId')}
|
||||
</span>
|
||||
</p>
|
||||
<p style="font-size: 13px; font-weight: 400; color: #2e3a59; margin-bottom: 15px;">
|
||||
-販売店名:
|
||||
<span style="color: #417DDC;">${this.session?.storeNm ?? ' - '}</span>
|
||||
</p>
|
||||
<p style="font-size: 13px; font-weight: 400; color: #2e3a59; margin-bottom: 15px;">
|
||||
-お問い合わせ内容:
|
||||
</p>
|
||||
<p style="font-size: 13px; font-weight: 400; color: #2e3a59;">
|
||||
${formData.get('contents')}
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { useSessionStore } from '@/store/session'
|
||||
import { InquiryRequest } from '@/types/Inquiry'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CONFIRM_MESSAGE, SUCCESS_MESSAGE, useAlertMsg, WARNING_MESSAGE } from '@/hooks/useAlertMsg'
|
||||
import { CONFIRM_MESSAGE, ERROR_MESSAGE, SUCCESS_MESSAGE, useAlertMsg, WARNING_MESSAGE } from '@/hooks/useAlertMsg'
|
||||
import { useInquiryFilterStore } from '@/store/inquiryFilterStore'
|
||||
|
||||
export default function RegistForm() {
|
||||
@ -106,9 +106,13 @@ export default function RegistForm() {
|
||||
showConfirm(
|
||||
CONFIRM_MESSAGE.SAVE_INQUIRY_CONFIRM,
|
||||
async () => {
|
||||
const res = await saveInquiry(formData)
|
||||
showSuccessAlert(SUCCESS_MESSAGE.SAVE_SUCCESS)
|
||||
router.push(`/inquiry/${res.qnaNo}`)
|
||||
try {
|
||||
const res = await saveInquiry(formData)
|
||||
showSuccessAlert(SUCCESS_MESSAGE.SAVE_SUCCESS)
|
||||
router.push(`/inquiry/${res.qnaNo}`)
|
||||
} catch (error) {
|
||||
showErrorAlert(ERROR_MESSAGE.SERVER_ERROR)
|
||||
}
|
||||
},
|
||||
() => null,
|
||||
)
|
||||
@ -149,7 +153,7 @@ export default function RegistForm() {
|
||||
選択してください
|
||||
</option>
|
||||
{commonCodeList
|
||||
.filter((code) => code.headCd === '204200')
|
||||
.filter((code) => code.headId === 'QNA_CLS_LRG_CD')
|
||||
.map((code) => (
|
||||
<option key={code.code} value={code.code}>
|
||||
{code.name}
|
||||
@ -157,7 +161,7 @@ export default function RegistForm() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{commonCodeList.filter((code) => code.refChar1 === inquiryRequest.qnaClsLrgCd).length > 0 && (
|
||||
{commonCodeList.filter((code) => code.refChar1 === inquiryRequest.qnaClsLrgCd).length > 0 && inquiryRequest.qnaClsLrgCd && (
|
||||
<div className="data-input mt5">
|
||||
<select
|
||||
className="select-form"
|
||||
@ -179,7 +183,7 @@ export default function RegistForm() {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{commonCodeList.filter((code) => code.refChar1 === inquiryRequest.qnaClsMidCd).length > 0 && (
|
||||
{commonCodeList.filter((code) => code.refChar1 === inquiryRequest.qnaClsMidCd).length > 0 && inquiryRequest.qnaClsMidCd && (
|
||||
<div className="data-input mt5">
|
||||
<select
|
||||
className="select-form"
|
||||
|
||||
@ -5,6 +5,7 @@ import { useInquiryFilterStore } from '@/store/inquiryFilterStore'
|
||||
import { useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAlertMsg } from '@/hooks/useAlertMsg'
|
||||
import { CommCode } from '@/types/CommCode'
|
||||
|
||||
/**
|
||||
* @description 문의사항 관련 기능을 제공하는 커스텀 훅
|
||||
@ -227,8 +228,12 @@ export function useInquiry(
|
||||
const isListQuery = false
|
||||
const shouldThrowError = false
|
||||
|
||||
const resp = await tryFunction(() => axiosInstance(null).get<{ data: CommonCode[] }>(`/api/qna`), isListQuery, shouldThrowError)
|
||||
return resp.data
|
||||
const resp = await tryFunction(
|
||||
() => axiosInstance(null).get<CommonCode[]>('/api/comm-code', { params: { headCode: 'QNA_CD' } }),
|
||||
isListQuery,
|
||||
shouldThrowError,
|
||||
)
|
||||
return resp?.data ?? []
|
||||
},
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
@ -242,6 +247,6 @@ export function useInquiry(
|
||||
isSavingInquiry,
|
||||
saveInquiry,
|
||||
downloadFile,
|
||||
commonCodeList: commonCodeList?.data ?? [],
|
||||
commonCodeList: commonCodeList ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import nodemailer from 'nodemailer'
|
||||
import { Attachment } from 'nodemailer/lib/mailer'
|
||||
|
||||
interface EmailParams {
|
||||
from: string
|
||||
@ -8,9 +9,10 @@ interface EmailParams {
|
||||
cc?: string | string[]
|
||||
subject: string
|
||||
content: string
|
||||
attachments?: Attachment[]
|
||||
}
|
||||
|
||||
export async function sendEmail({ from, to, cc, subject, content }: EmailParams): Promise<void> {
|
||||
export async function sendEmail({ from, to, cc, subject, content, attachments }: EmailParams): Promise<void> {
|
||||
// Create a transporter using SMTP
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
@ -27,6 +29,7 @@ export async function sendEmail({ from, to, cc, subject, content }: EmailParams)
|
||||
cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined,
|
||||
subject,
|
||||
html: content,
|
||||
attachments: attachments || [],
|
||||
}
|
||||
|
||||
try {
|
||||
@ -37,13 +40,3 @@ export async function sendEmail({ from, to, cc, subject, content }: EmailParams)
|
||||
throw new Error('Failed to send email')
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEmailTest() {
|
||||
await sendEmail({
|
||||
from: 'from@test.com',
|
||||
to: 'test@test.com',
|
||||
cc: 'test2@test.com',
|
||||
subject: 'Test Email',
|
||||
content: '<h1>Hello</h1><p>This is a test email.</p>',
|
||||
})
|
||||
}
|
||||
|
||||
@ -185,6 +185,7 @@ export type InquirySaveResponse = {
|
||||
*/
|
||||
export type CommonCode = {
|
||||
headCd: string
|
||||
headId: string
|
||||
code: string
|
||||
name: string
|
||||
refChar1: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user