File name
Commit message
Commit date
08-14
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'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,
BOARD_FILE_MODULE_ID,
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';
/**
* 게시판(공지사항·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 이하만 올릴 수 있습니다.`;
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<string> {
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, BOARD_FILE_MODULE_ID);
}
/** 등록·수정이 공유하는 입력 읽기. */
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<BoardPostFormState> {
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 };
}
await createBoardPost(boardType, validation.values);
revalidatePath(BOARD_PATHS[boardType]);
return { status: 'success' };
}
/** 시안 A_BOA_008_p / 016_p — 공지사항·FAQ 수정. 구분은 읽기 전용이라 검증 대상이 아니다. */
export async function updateBoardPostAction(
_prevState: BoardPostFormState,
formData: FormData
): Promise<BoardPostFormState> {
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 };
}
await updateBoardPost(boardType, bbsId, validation.values);
revalidatePath(BOARD_PATHS[boardType]);
return { status: 'success' };
}
/** 시안 A_BOA_013_p — 1:1문의 답변 저장. */
export async function saveInquiryAnswerAction(
_prevState: InquiryAnswerFormState,
formData: FormData
): Promise<InquiryAnswerFormState> {
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 };
}
await saveInquiryAnswer(bbsId, validation.values);
revalidatePath(BOARD_PATHS.inquiry);
return { status: 'success' };
}
/**
* 삭제(논리 삭제) — 확인 얼럿은 화면이 띄우고 여기서는 권한과 입력만 확인한다.
*
* 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
* (prevState, formData) 규약 대신 값을 직접 받는다.
*/
export async function deleteBoardPostAction(
boardType: BoardType,
bbsId: string
): Promise<BoardPostFormState> {
await verifySession();
if (!BOARD_TYPES.includes(boardType) || !bbsId) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
await deleteBoardPost(bbsId);
revalidatePath(BOARD_PATHS[boardType]);
return { status: 'success' };
}