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 { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { verifySession } from '@/lib/auth/dal';
import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
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,
MAX_ATTACHMENT_BYTES,
isAllowedFileExtension,
type FileModule,
} from '@/lib/domain/file-module';
/**
* 콘텐츠관리 Server Action — 세 종류가 같은 파일을 쓴다(폼 구조가 거의 같다).
*
* 이슈: 등록·수정·삭제 API가 백엔드에 아직 없다. Repository가 예상 규약대로 호출하고 여기서
* 실패 사유를 그대로 화면에 돌려준다.
*/
const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
const UPLOAD_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<File | null> {
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,
module: FileModule
): Promise<string> {
if (!file) {
return retainedId;
}
if (file.size > MAX_ATTACHMENT_BYTES) {
throw new Error(OVERSIZE_MESSAGE);
}
if (!isAllowedFileExtension(module, file.name)) {
throw new Error(
`${module.description}에는 ${module.extensions.join(', ')} 파일만 올릴 수 있습니다.`
);
}
return uploadAttachment(file, module.id);
}
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<ContentFormState> {
await verifySession();
if (readString(formData, 'videoUploading') === '1') {
return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
}
const validation = validateShortsForm({
schoolCode: readString(formData, 'schoolCode'),
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',
}, await fetchSchoolGradeCodes());
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
try {
const values = {
...validation.values,
thumbnailFileId: await resolveFileId(
readFiles(formData, 'thumbnailFile')[0] ?? null,
validation.values.thumbnailFileId,
FILE_MODULE.contentThumbnail
),
};
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<ContentFormState> {
await verifySession();
const cntntsSn = readString(formData, 'cntntsSn');
if (readString(formData, 'videoUploading') === '1') {
return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
}
const validation = validateShortsForm({
schoolCode: readString(formData, 'schoolCode'),
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',
}, await fetchSchoolGradeCodes());
if (!cntntsSn) {
return { status: 'error', message: '요청이 올바르지 않습니다.' };
}
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
try {
const values = {
...validation.values,
thumbnailFileId: await resolveFileId(
readFiles(formData, 'thumbnailFile')[0] ?? null,
validation.values.thumbnailFileId,
FILE_MODULE.contentThumbnail
),
};
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<string[]> {
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] ?? '',
FILE_MODULE.toonContent
);
if (id) {
ids.push(id);
}
}
return ids;
}
export async function createComicAction(
_prevState: ContentFormState,
formData: FormData
): Promise<ContentFormState> {
await verifySession();
let cutFileIds: string[];
let thumbnailFileId: string;
try {
cutFileIds = await resolveCutFileIds(formData);
thumbnailFileId = await resolveFileId(
readFiles(formData, 'thumbnailFile')[0] ?? null,
readString(formData, 'thumbnailFileId'),
FILE_MODULE.contentThumbnail
);
} catch (error) {
return failure(error, UPLOAD_FAILED_MESSAGE);
}
const validation = validateComicForm({
schoolCode: readString(formData, 'schoolCode'),
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',
}, await fetchSchoolGradeCodes());
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<ContentFormState> {
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'),
FILE_MODULE.contentThumbnail
);
} catch (error) {
return failure(error, UPLOAD_FAILED_MESSAGE);
}
const validation = validateComicForm({
schoolCode: readString(formData, 'schoolCode'),
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',
}, await fetchSchoolGradeCodes());
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 {
schoolCode: readString(formData, 'schoolCode'),
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<ContentFormState> {
await verifySession();
const validation = validateQuizForm(readQuizValues(formData), await fetchSchoolGradeCodes());
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<ContentFormState> {
await verifySession();
const quizSn = readString(formData, 'quizSn');
if (!quizSn) {
return { status: 'error', message: '요청이 올바르지 않습니다.' };
}
const validation = validateQuizForm(readQuizValues(formData), await fetchSchoolGradeCodes());
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<ContentFormState> {
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<ContentFormState> {
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' };
}