File name
Commit message
Commit date
File name
Commit message
Commit date
09-03
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' };
}