'use server'; import { revalidatePath } from 'next/cache'; import { verifySession } from '@/lib/auth/dal'; import { createBoardPost, deleteBoardPost, saveInquiryAnswer, updateBoardPost, } from '@/lib/data/repositories/board-post-repository'; import { MAX_ATTACHMENT_BYTES, uploadAttachment, } from '@/lib/data/repositories/file-repository'; import { ANSWER_STATUS_DONE, type BoardType, } from '@/lib/domain/board-post'; import { validateBoardPostForm, validateInquiryAnswerForm, type BoardPostFormState, type InquiryAnswerFormState, } from '@/lib/domain/board-post-form'; import { BOARD_PATHS } from '@/lib/domain/board-post-query'; import { FILE_MODULE_ID } from '@/lib/domain/file-module'; /** * 게시판(공지사항·1:1문의·FAQ) 등록/수정/삭제/답변 Server Action. * * 세 화면이 같은 API를 쓰므로 Action도 한 파일이 담당하고, 어느 게시판인지는 폼에 실린 * `boardType`으로 가른다. **그 값도 신뢰하지 않는다** — 허용 목록에 없으면 즉시 거절한다. * * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §8). * * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/board-post-form.ts`의 규칙을 호출). * * `*FormState` 타입과 그 초깃값(`INITIAL_*`)은 이 파일이 아니라 `lib/domain/board-post-form.ts`에 * 있다 — `'use server'` 파일은 async 함수 외의 값을 export할 수 없다(그 쪽 주석에 근거를 적어 두었다). * * 첨부파일은 **같은 폼에 실려 온 File을 이 Action이 직접 업로드**한다. 브라우저에서 먼저 업로드해 * id를 받아 두는 방식보다 단순하고(왕복 1회), 업로드 API가 요구하는 관리자 토큰이 서버에만 * 있으므로 자연스럽다 — 토큰은 httpOnly 세션 안에 있어 브라우저가 꺼낼 수 없다. */ const BOARD_TYPES: readonly BoardType[] = ['notice', 'inquiry', 'faq']; const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; const UPLOAD_FAILED_MESSAGE = '첨부파일을 업로드하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const OVERSIZE_MESSAGE = `첨부파일은 ${Math.floor( MAX_ATTACHMENT_BYTES / (1024 * 1024) )}MB 이하만 올릴 수 있습니다.`; const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; async function runWrite( write: () => Promise, fallback: string ): Promise { try { await write(); return null; } catch (error) { return { status: 'error', message: error instanceof Error ? error.message : fallback, }; } } function readString(formData: FormData, key: string): string { const value = formData.get(key); return typeof value === 'string' ? value : ''; } /** 체크박스·라디오는 값이 있을 때만 전송되므로 "Y로 온 경우"만 참으로 본다. */ function readBoolean(formData: FormData, key: string): boolean { return readString(formData, key) === 'Y'; } function readBoardType(formData: FormData): BoardType | null { const raw = readString(formData, 'boardType'); return BOARD_TYPES.find((type) => type === raw) ?? null; } /** * 새로 첨부된 파일이 있으면 업로드하고 새 id를, 없으면 기존 id를 그대로 돌려준다. * * 빈 파일(파일을 고르지 않으면 브라우저가 size 0으로 보낸다)은 "선택 안 함"이므로 무시한다 — * 이때 기존 첨부를 지우지 않는 것이 의도다(수정 화면에서 첨부를 건드리지 않은 경우). */ async function resolveAttachmentId(formData: FormData): Promise { const existingId = readString(formData, 'attachmentId'); const file = formData.get('attachment'); if (!(file instanceof File) || file.size === 0) { return existingId; } if (file.size > MAX_ATTACHMENT_BYTES) { throw new Error(OVERSIZE_MESSAGE); } return uploadAttachment(file, FILE_MODULE_ID.board); } /** 등록·수정이 공유하는 입력 읽기. */ function readPostValues(formData: FormData, attachmentId: string) { return { categoryCode: readString(formData, 'categoryCode'), typeCode: readString(formData, 'typeCode'), title: readString(formData, 'title'), content: readString(formData, 'content'), attachmentId, isPinned: readBoolean(formData, 'isPinned'), isVisible: readBoolean(formData, 'isVisible'), isPushEnabled: readBoolean(formData, 'isPushEnabled'), startDate: readString(formData, 'startDate'), endDate: readString(formData, 'endDate'), }; } /** 시안 A_BOA_007_p / 015_p — 공지사항·FAQ 신규 등록. */ export async function createBoardPostAction( _prevState: BoardPostFormState, formData: FormData ): Promise { await verifySession(); const boardType = readBoardType(formData); if (boardType === null || boardType === 'inquiry') { // 1:1문의는 사용자가 작성하는 글이라 관리자 등록 경로가 없다(사용자 확인 사항). return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } let attachmentId: string; try { attachmentId = await resolveAttachmentId(formData); } catch (error) { return { status: 'error', message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, }; } const validation = validateBoardPostForm( readPostValues(formData, attachmentId), { boardType, isCategoryEditable: true } ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } const failure = await runWrite( () => createBoardPost(boardType, validation.values), SAVE_FAILED_MESSAGE ); if (failure) { return failure; } revalidatePath(BOARD_PATHS[boardType]); return { status: 'success' }; } /** 시안 A_BOA_008_p / 016_p — 공지사항·FAQ 수정. 구분은 읽기 전용이라 검증 대상이 아니다. */ export async function updateBoardPostAction( _prevState: BoardPostFormState, formData: FormData ): Promise { await verifySession(); const boardType = readBoardType(formData); const bbsId = readString(formData, 'id'); if (boardType === null || boardType === 'inquiry' || !bbsId) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } let attachmentId: string; try { attachmentId = await resolveAttachmentId(formData); } catch (error) { return { status: 'error', message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, }; } const validation = validateBoardPostForm( readPostValues(formData, attachmentId), { boardType, isCategoryEditable: false } ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } const failure = await runWrite( () => updateBoardPost(boardType, bbsId, validation.values), SAVE_FAILED_MESSAGE ); if (failure) { return failure; } revalidatePath(BOARD_PATHS[boardType]); return { status: 'success' }; } /** 시안 A_BOA_013_p — 1:1문의 답변 저장. */ export async function saveInquiryAnswerAction( _prevState: InquiryAnswerFormState, formData: FormData ): Promise { await verifySession(); const bbsId = readString(formData, 'id'); if (!bbsId) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } let attachmentId: string; try { attachmentId = await resolveAttachmentId(formData); } catch (error) { return { status: 'error', message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, }; } const validation = validateInquiryAnswerForm( { answerStatusCode: readString(formData, 'answerStatusCode'), answerContent: readString(formData, 'answerContent'), attachmentId, }, { doneStatusCode: ANSWER_STATUS_DONE } ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } const failure = await runWrite( () => saveInquiryAnswer(bbsId, validation.values), SAVE_FAILED_MESSAGE ); if (failure) { return failure; } revalidatePath(BOARD_PATHS.inquiry); return { status: 'success' }; } /** * 삭제(논리 삭제) — 확인 얼럿은 화면이 띄우고 여기서는 권한과 입력만 확인한다. * * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 * (prevState, formData) 규약 대신 값을 직접 받는다. */ export async function deleteBoardPostAction( boardType: BoardType, bbsId: string ): Promise { await verifySession(); if (!BOARD_TYPES.includes(boardType) || !bbsId) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } const failure = await runWrite( () => deleteBoardPost(bbsId), DELETE_FAILED_MESSAGE ); if (failure) { return failure; } revalidatePath(BOARD_PATHS[boardType]); return { status: 'success' }; }