File name
Commit message
Commit date
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 { unstable_rethrow } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { verifySession } from '@/lib/auth/dal';
import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
import { CODE_GROUP } from '@/lib/domain/common-code';
import {
createBoardPost,
deleteBoardPost,
findBoardPostById,
saveInquiryAnswer,
updateBoardPost,
} from '@/lib/data/repositories/board-post-repository';
import { uploadAttachment } from '@/lib/data/repositories/file-repository';
import {
ANSWER_STATUS_DONE,
type BoardPost,
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,
MAX_ATTACHMENT_BYTES,
allowedExtensions,
isAllowedFileExtension,
} 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 = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
const LOAD_FAILED_MESSAGE = '게시물을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.';
const GONE_MESSAGE = '이미 삭제된 게시물입니다. 목록을 새로고침해 주세요.';
/** FAQ 유형 허용 목록. 화면이 보낸 코드를 그대로 믿지 않기 위해 저장 직전에 다시 조회한다. */
async function fetchFaqTypeCodes(boardType: BoardType): Promise<string[]> {
if (boardType !== 'faq') {
return [];
}
const codes = await fetchCommonCodes(CODE_GROUP.faqType);
return codes.map((code) => code.code);
}
async function runWrite(
write: () => Promise<void>,
fallback: string
): Promise<BoardPostFormState | null> {
try {
await write();
return null;
} catch (error) {
// 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
unstable_rethrow(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<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);
}
if (!isAllowedFileExtension(FILE_MODULE.board, file.name)) {
throw new Error(
`${allowedExtensions(FILE_MODULE.board).join(', ')} 파일만 올릴 수 있습니다.`
);
}
return uploadAttachment(file, FILE_MODULE.board.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) {
unstable_rethrow(error);
return {
status: 'error',
message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE,
};
}
const validation = validateBoardPostForm(
readPostValues(formData, attachmentId),
{
boardType,
isCategoryEditable: true,
allowedTypeCodes: await fetchFaqTypeCodes(boardType),
}
);
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<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) {
unstable_rethrow(error);
return {
status: 'error',
message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE,
};
}
const validation = validateBoardPostForm(
readPostValues(formData, attachmentId),
{
boardType,
isCategoryEditable: false,
allowedTypeCodes: await fetchFaqTypeCodes(boardType),
}
);
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<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) {
unstable_rethrow(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,
allowedStatusCodes: (
await fetchCommonCodes(CODE_GROUP.inquiryAnswerStatus)
).map((code) => code.code),
}
);
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 loadBoardPostAction(
bbsId: string
): Promise<
{ status: 'success'; post: BoardPost } | { status: 'error'; message: string }
> {
await verifySession();
try {
const post = await findBoardPostById(bbsId);
if (!post) {
return { status: 'error', message: GONE_MESSAGE };
}
return { status: 'success', post };
} catch (error) {
unstable_rethrow(error);
return { status: 'error', message: LOAD_FAILED_MESSAGE };
}
}
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 };
}
const failure = await runWrite(
() => deleteBoardPost(bbsId),
DELETE_FAILED_MESSAGE
);
if (failure) {
return failure;
}
revalidatePath(BOARD_PATHS[boardType]);
return { status: 'success' };
}