'use server'; import { revalidatePath } from 'next/cache'; import { redirect, unstable_rethrow } from 'next/navigation'; import { verifySession } from '@/lib/auth/dal'; import { BackendRequestError } from '@/lib/http/backend-fetch'; import { fetchQuizAnswerMessages, fetchSchoolGradeCodes, } from '@/lib/data/repositories/common-code-repository'; import { uploadAttachment, uploadAttachments, } from '@/lib/data/repositories/file-repository'; import { buildComicPayload, buildShortsPayload, createContent, deleteContent, updateContent, } from '@/lib/data/repositories/content-repository'; import { createQuiz, deleteQuiz, updateQuiz, } from '@/lib/data/repositories/content-quiz-repository'; import { CONTENT_TYPE_CODE, QUIZ_ANSWER_OPTIONS, type ContentKind, } from '@/lib/domain/content'; import { ContentInputError, validateComicForm, validateQuizForm, validateShortsForm, type ContentFormState, } from '@/lib/domain/content-form'; import { contentListPath } from '@/lib/domain/content-query'; import { FILE_MODULE, MAX_ATTACHMENT_BYTES, allowedExtensions, isAllowedFileExtension, type FileModule, } from '@/lib/domain/file-module'; /** * 콘텐츠관리 Server Action — 세 종류가 같은 파일을 쓴다(폼 구조가 거의 같다). * * 이슈: 등록·수정·삭제 API가 백엔드에 아직 없다. Repository가 예상 규약대로 호출하고 여기서 * 실패 사유를 그대로 화면에 돌려준다. */ const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; const VIDEO_FAILED_MESSAGE = '영상을 올리지 못했습니다. 파일을 지우거나 다시 골라 주세요 — 이대로 저장하면 영상 없이 저장됩니다.'; const VIDEO_UPLOADING_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 : ''; } function readFiles(formData: FormData, key: string): Array { return formData .getAll(key) .map((entry) => entry instanceof File && entry.size > 0 ? entry : null ); } function readGrades(formData: FormData): string[] { return readString(formData, 'grades') .split(',') .map((grade) => grade.trim()) .filter(Boolean); } function readKeywords(formData: FormData) { const names = formData.getAll('keyword'); const descriptions = formData.getAll('keywordDescription'); return names.map((name, index) => ({ keyword: typeof name === 'string' ? name : '', description: typeof descriptions[index] === 'string' ? (descriptions[index] as string) : '', })); } /** * 새로 고른 파일을 알맞은 저장소에 올려 파일 ID를 얻는다. 고르지 않았으면 폼이 들고 있던 기존 * ID를 그대로 쓴다. 확장자는 백엔드가 사유 없이 실패하기 전에 여기서 먼저 잡는다. */ function assertUploadable(file: File, module: FileModule): void { if (file.size > MAX_ATTACHMENT_BYTES) { throw new ContentInputError(OVERSIZE_MESSAGE); } if (!isAllowedFileExtension(module, file.name)) { throw new ContentInputError( `${module.description}에는 ${allowedExtensions(module).join(', ')} 파일만 올릴 수 있습니다.` ); } } async function resolveFileId( file: File | null, retainedId: string, module: FileModule ): Promise { if (!file) { return retainedId; } assertUploadable(file, module); return uploadAttachment(file, module.id); } /** * 쓰기 동작 한 건을 감싼다 — 여기서 새는 예외가 하나라도 있으면 Next가 직렬화하지 못하고 * `Cannot read properties of undefined (reading 'stack')`로 끝난다. 코드표 조회·업로드·저장이 * 모두 이 안에서 일어나야 한다. `redirect()`는 예외로 흐름을 끊으므로 반드시 밖에서 부른다. */ async function runWrite( run: () => Promise ): Promise { try { return await run(); } catch (error) { // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. unstable_rethrow(error); // 진짜 원인은 여기에만 남는다 — 화면에는 사람이 고칠 수 있는 사유만 나간다. console.error('[contents] 저장 실패', error); return failure(error, SAVE_FAILED_MESSAGE); } } /** * 사유를 화면에 낼지 정한다. 백엔드가 돌려준 메시지와 입력 오류만 그대로 보이고, 나머지는 * 개발자용 문구(예: TypeError)라 일반 문구로 바꾼다 — 그대로 내면 사용자가 할 수 있는 일이 없다. */ function failure(error: unknown, fallback: string): ContentFormState { const showable = error instanceof ContentInputError || error instanceof BackendRequestError; return { status: 'error', message: showable && error.message ? error.message : fallback, }; } function readShortsValues(formData: FormData) { return { schoolCode: readString(formData, 'schoolCode'), grades: readGrades(formData), title: readString(formData, 'title'), subject: readString(formData, 'subject'), keywords: readKeywords(formData), videoFileId: readString(formData, 'videoFileId'), thumbnailFileId: readString(formData, 'thumbnailFileId'), isVisible: readString(formData, 'isVisible') === 'true', }; } function readComicValues(formData: FormData) { return { schoolCode: readString(formData, 'schoolCode'), grades: readGrades(formData), name: readString(formData, 'name'), subject: readString(formData, 'subject'), keywords: readKeywords(formData), message: readString(formData, 'message'), thumbnailFileId: '', cutFileIds: [] as string[], isVisible: readString(formData, 'isVisible') === 'true', }; } function readQuizValues(formData: FormData) { return { schoolCode: readString(formData, 'schoolCode'), grades: readGrades(formData), level: readString(formData, 'level'), keywords: readKeywords(formData), question: readString(formData, 'question'), answer: readString(formData, 'answer'), // O·X 두 줄을 각각 읽는다 — 정답이 아닌 쪽도 함께 보낸다. answerMessages: QUIZ_ANSWER_OPTIONS.map((answer) => ({ answer, code: readString(formData, `answerMessage${answer}Code`), text: readString(formData, `answerMessage${answer}Text`), })), explanation: readString(formData, 'explanation'), isVisible: readString(formData, 'isVisible') === 'true', }; } async function resolveThumbnailId(formData: FormData): Promise { return resolveFileId( readFiles(formData, 'thumbnailFile')[0] ?? null, readString(formData, 'thumbnailFileId'), FILE_MODULE.contentThumbnail ); } /** * 컷은 **한 첨부 아래** 올린다 — 백엔드가 `fileSn`을 1,2,3…으로 채번한다. 그래서 돌려주는 * 배열은 컷 수만큼 같은 아이디가 반복된다(컷을 가리키는 건 순번이지 아이디가 아니다). * * 한 컷만 바꿔도 묶음 전체를 다시 올려야 순번이 맞으므로, 새로 고른 것이 하나라도 있으면 * 고른 것들로 묶음을 새로 만든다. 하나도 고르지 않았으면 기존 아이디를 그대로 쓴다. */ async function resolveCutFileIds(formData: FormData): Promise { // 고르지 않은 칸은 null로 오므로 걸러 낸다. const files = readFiles(formData, 'cutFile').filter( (file): file is File => file !== null ); const retained = formData .getAll('cutFileId') .map((entry) => (typeof entry === 'string' ? entry : '')) .filter(Boolean); if (files.length === 0) { return retained; } for (const file of files) { assertUploadable(file, FILE_MODULE.toonContent); } const attachmentId = await uploadAttachments(files, FILE_MODULE.toonContent.id); return files.map(() => attachmentId); } export async function createShortsAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); if (readString(formData, 'videoUploading') === '1') { return { status: 'error', message: VIDEO_UPLOADING_MESSAGE }; } if (readString(formData, 'videoUploadFailed') === '1') { return { status: 'error', message: VIDEO_FAILED_MESSAGE }; } const failed = await runWrite(async () => { const validation = validateShortsForm( readShortsValues(formData), await fetchSchoolGradeCodes() ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } await createContent( 'shorts', buildShortsPayload({ ...validation.values, thumbnailFileId: await resolveThumbnailId(formData), }), CONTENT_TYPE_CODE.shorts ); return null; }); if (failed) { return failed; } revalidatePath(contentListPath('shorts')); redirect(contentListPath('shorts')); } export async function updateShortsAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const cntntsId = readString(formData, 'cntntsId'); if (!cntntsId) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } if (readString(formData, 'videoUploading') === '1') { return { status: 'error', message: VIDEO_UPLOADING_MESSAGE }; } if (readString(formData, 'videoUploadFailed') === '1') { return { status: 'error', message: VIDEO_FAILED_MESSAGE }; } const failed = await runWrite(async () => { const validation = validateShortsForm( readShortsValues(formData), await fetchSchoolGradeCodes() ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } await updateContent( 'shorts', cntntsId, buildShortsPayload({ ...validation.values, thumbnailFileId: await resolveThumbnailId(formData), }) ); return null; }); if (failed) { return failed; } revalidatePath(contentListPath('shorts')); redirect(contentListPath('shorts')); } export async function createComicAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const failed = await runWrite(async () => { const validation = validateComicForm( { ...readComicValues(formData), thumbnailFileId: await resolveThumbnailId(formData), cutFileIds: await resolveCutFileIds(formData), }, await fetchSchoolGradeCodes() ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } await createContent( 'comic', buildComicPayload(validation.values), CONTENT_TYPE_CODE.comic ); return null; }); if (failed) { return failed; } revalidatePath(contentListPath('comic')); redirect(contentListPath('comic')); } export async function updateComicAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const cntntsId = readString(formData, 'cntntsId'); if (!cntntsId) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } const failed = await runWrite(async () => { const validation = validateComicForm( { ...readComicValues(formData), thumbnailFileId: await resolveThumbnailId(formData), cutFileIds: await resolveCutFileIds(formData), }, await fetchSchoolGradeCodes() ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } await updateContent('comic', cntntsId, buildComicPayload(validation.values)); return null; }); if (failed) { return failed; } revalidatePath(contentListPath('comic')); redirect(contentListPath('comic')); } export async function createQuizAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const failed = await runWrite(async () => { const validation = validateQuizForm( readQuizValues(formData), await fetchSchoolGradeCodes(), await fetchQuizAnswerMessages() ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } await createQuiz(validation.values); return null; }); if (failed) { return failed; } revalidatePath(contentListPath('quiz')); redirect(contentListPath('quiz')); } export async function updateQuizAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const quizId = readString(formData, 'quizId'); if (!quizId) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } const failed = await runWrite(async () => { const validation = validateQuizForm( readQuizValues(formData), await fetchSchoolGradeCodes(), await fetchQuizAnswerMessages() ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } await updateQuiz(quizId, validation.values); return null; }); if (failed) { return failed; } revalidatePath(contentListPath('quiz')); redirect(contentListPath('quiz')); } export async function deleteContentAction( kind: ContentKind, id: string ): Promise { await verifySession(); if (!id) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } try { if (kind === 'quiz') { await deleteQuiz(id); } else { await deleteContent(kind, id); } } catch (error) { unstable_rethrow(error); return failure(error, DELETE_FAILED_MESSAGE); } revalidatePath(contentListPath(kind)); return { status: 'success' }; }