'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { verifySession } from '@/lib/auth/dal'; import { uploadAttachment } from '@/lib/data/repositories/file-repository'; import { buildComicPayload, buildShortsPayload, createContent, deleteContent, updateContent, } from '@/lib/data/repositories/content-repository'; import { createQuiz, deleteQuiz, updateQuiz, uploadQuizExcel, } from '@/lib/data/repositories/content-quiz-repository'; import { CONTENT_TYPE_CODE, type ContentKind } from '@/lib/domain/content'; import { validateComicForm, validateQuizForm, validateShortsForm, type ContentFormState, } from '@/lib/domain/content-form'; import { contentListPath } from '@/lib/domain/content-query'; import { FILE_MODULE_ID, MAX_ATTACHMENT_BYTES } from '@/lib/domain/file-module'; /** * 콘텐츠관리 Server Action — 세 종류가 같은 파일을 쓴다(폼 구조가 거의 같다). * * 이슈: 등록·수정·삭제 API가 백엔드에 아직 없다. Repository가 예상 규약대로 호출하고 여기서 * 실패 사유를 그대로 화면에 돌려준다. */ const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const DELETE_FAILED_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 : ''; } 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를 그대로 쓴다. */ async function resolveFileId( file: File | null, retainedId: string ): Promise { if (!file) { return retainedId; } if (file.size > MAX_ATTACHMENT_BYTES) { throw new Error(OVERSIZE_MESSAGE); } return uploadAttachment(file, FILE_MODULE_ID.content); } function failure(error: unknown, fallback: string): ContentFormState { return { status: 'error', message: error instanceof Error && error.message ? error.message : fallback, }; } export async function createShortsAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const validation = validateShortsForm({ schoolLevel: readString(formData, 'schoolLevel'), grades: readGrades(formData), title: readString(formData, 'title'), subject: readString(formData, 'subject'), keywords: readKeywords(formData), message: readString(formData, 'message'), videoFileId: readString(formData, 'videoFileId'), thumbnailFileId: readString(formData, 'thumbnailFileId'), isVisible: readString(formData, 'isVisible') === 'true', }); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { const values = { ...validation.values, videoFileId: await resolveFileId( readFiles(formData, 'videoFile')[0] ?? null, validation.values.videoFileId ), thumbnailFileId: await resolveFileId( readFiles(formData, 'thumbnailFile')[0] ?? null, validation.values.thumbnailFileId ), }; await createContent( 'shorts', buildShortsPayload(values), CONTENT_TYPE_CODE.shorts ); } catch (error) { return failure(error, SAVE_FAILED_MESSAGE); } revalidatePath(contentListPath('shorts')); redirect(contentListPath('shorts')); } export async function updateShortsAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const cntntsSn = readString(formData, 'cntntsSn'); const validation = validateShortsForm({ schoolLevel: readString(formData, 'schoolLevel'), grades: readGrades(formData), title: readString(formData, 'title'), subject: readString(formData, 'subject'), keywords: readKeywords(formData), message: readString(formData, 'message'), videoFileId: readString(formData, 'videoFileId'), thumbnailFileId: readString(formData, 'thumbnailFileId'), isVisible: readString(formData, 'isVisible') === 'true', }); if (!cntntsSn) { return { status: 'error', message: '요청이 올바르지 않습니다.' }; } if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { const values = { ...validation.values, videoFileId: await resolveFileId( readFiles(formData, 'videoFile')[0] ?? null, validation.values.videoFileId ), thumbnailFileId: await resolveFileId( readFiles(formData, 'thumbnailFile')[0] ?? null, validation.values.thumbnailFileId ), }; await updateContent('shorts', cntntsSn, buildShortsPayload(values)); } catch (error) { return failure(error, SAVE_FAILED_MESSAGE); } revalidatePath(contentListPath('shorts')); redirect(contentListPath('shorts')); } async function resolveCutFileIds(formData: FormData): Promise { const files = readFiles(formData, 'cutFile'); const retained = formData .getAll('cutFileId') .map((entry) => (typeof entry === 'string' ? entry : '')); const ids: string[] = []; for (const [index, file] of files.entries()) { const id = await resolveFileId(file, retained[index] ?? ''); if (id) { ids.push(id); } } return ids; } export async function createComicAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); let cutFileIds: string[]; let thumbnailFileId: string; try { cutFileIds = await resolveCutFileIds(formData); thumbnailFileId = await resolveFileId( readFiles(formData, 'thumbnailFile')[0] ?? null, readString(formData, 'thumbnailFileId') ); } catch (error) { return failure(error, UPLOAD_FAILED_MESSAGE); } const validation = validateComicForm({ schoolLevel: readString(formData, 'schoolLevel'), grades: readGrades(formData), name: readString(formData, 'name'), subject: readString(formData, 'subject'), keywords: readKeywords(formData), message: readString(formData, 'message'), thumbnailFileId, cutFileIds, isVisible: readString(formData, 'isVisible') === 'true', }); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { await createContent( 'comic', buildComicPayload(validation.values), CONTENT_TYPE_CODE.comic ); } catch (error) { return failure(error, SAVE_FAILED_MESSAGE); } revalidatePath(contentListPath('comic')); redirect(contentListPath('comic')); } export async function updateComicAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const cntntsSn = readString(formData, 'cntntsSn'); if (!cntntsSn) { return { status: 'error', message: '요청이 올바르지 않습니다.' }; } let cutFileIds: string[]; let thumbnailFileId: string; try { cutFileIds = await resolveCutFileIds(formData); thumbnailFileId = await resolveFileId( readFiles(formData, 'thumbnailFile')[0] ?? null, readString(formData, 'thumbnailFileId') ); } catch (error) { return failure(error, UPLOAD_FAILED_MESSAGE); } const validation = validateComicForm({ schoolLevel: readString(formData, 'schoolLevel'), grades: readGrades(formData), name: readString(formData, 'name'), subject: readString(formData, 'subject'), keywords: readKeywords(formData), message: readString(formData, 'message'), thumbnailFileId, cutFileIds, isVisible: readString(formData, 'isVisible') === 'true', }); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { await updateContent('comic', cntntsSn, buildComicPayload(validation.values)); } catch (error) { return failure(error, SAVE_FAILED_MESSAGE); } revalidatePath(contentListPath('comic')); redirect(contentListPath('comic')); } function readQuizValues(formData: FormData) { return { schoolLevel: readString(formData, 'schoolLevel'), grades: readGrades(formData), level: readString(formData, 'level'), keywords: readKeywords(formData), question: readString(formData, 'question'), answer: readString(formData, 'answer'), answerMessageCode: readString(formData, 'answerMessageCode'), answerMessageText: readString(formData, 'answerMessageText'), explanation: readString(formData, 'explanation'), isVisible: readString(formData, 'isVisible') === 'true', }; } export async function createQuizAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const validation = validateQuizForm(readQuizValues(formData)); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { await createQuiz(validation.values); } catch (error) { return failure(error, SAVE_FAILED_MESSAGE); } revalidatePath(contentListPath('quiz')); redirect(contentListPath('quiz')); } export async function updateQuizAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const quizSn = readString(formData, 'quizSn'); if (!quizSn) { return { status: 'error', message: '요청이 올바르지 않습니다.' }; } const validation = validateQuizForm(readQuizValues(formData)); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } try { await updateQuiz(quizSn, validation.values); } catch (error) { return failure(error, SAVE_FAILED_MESSAGE); } revalidatePath(contentListPath('quiz')); redirect(contentListPath('quiz')); } export async function deleteContentAction( kind: ContentKind, id: string ): Promise { await verifySession(); if (!id) { return { status: 'error', message: '요청이 올바르지 않습니다.' }; } try { if (kind === 'quiz') { await deleteQuiz(id); } else { await deleteContent(kind, id); } } catch (error) { return failure(error, DELETE_FAILED_MESSAGE); } revalidatePath(contentListPath(kind)); return { status: 'success' }; } /** 금융OX퀴즈 엑셀 일괄등록. 한 행이라도 검증에 실패하면 백엔드가 전체를 되돌린다. */ export async function uploadQuizExcelAction( _prevState: ContentFormState, formData: FormData ): Promise { await verifySession(); const file = readFiles(formData, 'excelFile')[0] ?? null; if (!file) { return { status: 'error', message: '엑셀 파일을 선택해 주세요.' }; } try { await uploadQuizExcel(file); } catch (error) { return failure(error, '엑셀을 등록하지 못했습니다.'); } revalidatePath(contentListPath('quiz')); return { status: 'success' }; }