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 { BackendRequestError } from '@/lib/http/backend-fetch';
import { 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, 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<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를 그대로 쓴다. 확장자는 백엔드가 사유 없이 실패하기 전에 여기서 먼저 잡는다.
*/
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<string> {
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<ContentFormState | null>
): Promise<ContentFormState | null> {
try {
return await run();
} catch (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),
points: readString(formData, 'quizPoint'),
title: readString(formData, 'quizTitle'),
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',
};
}
async function resolveThumbnailId(formData: FormData): Promise<string> {
return resolveFileId(
readFiles(formData, 'thumbnailFile')[0] ?? null,
readString(formData, 'thumbnailFileId'),
FILE_MODULE.contentThumbnail
);
}
/**
* 컷은 **한 첨부 아래** 올린다 — 백엔드가 `fileSn`을 1,2,3…으로 채번한다. 그래서 돌려주는
* 배열은 컷 수만큼 같은 아이디가 반복된다(컷을 가리키는 건 순번이지 아이디가 아니다).
*
* 한 컷만 바꿔도 묶음 전체를 다시 올려야 순번이 맞으므로, 새로 고른 것이 하나라도 있으면
* 고른 것들로 묶음을 새로 만든다. 하나도 고르지 않았으면 기존 아이디를 그대로 쓴다.
*/
async function resolveCutFileIds(formData: FormData): Promise<string[]> {
// 고르지 않은 칸은 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<ContentFormState> {
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<ContentFormState> {
await verifySession();
const cntntsSn = readString(formData, 'cntntsSn');
if (!cntntsSn) {
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',
cntntsSn,
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<ContentFormState> {
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<ContentFormState> {
await verifySession();
const cntntsSn = readString(formData, 'cntntsSn');
if (!cntntsSn) {
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', cntntsSn, buildComicPayload(validation.values));
return null;
});
if (failed) {
return failed;
}
revalidatePath(contentListPath('comic'));
redirect(contentListPath('comic'));
}
export async function createQuizAction(
_prevState: ContentFormState,
formData: FormData
): Promise<ContentFormState> {
await verifySession();
const failed = await runWrite(async () => {
const validation = validateQuizForm(
readQuizValues(formData),
await fetchSchoolGradeCodes()
);
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<ContentFormState> {
await verifySession();
const quizSn = readString(formData, 'quizSn');
if (!quizSn) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
const failed = await runWrite(async () => {
const validation = validateQuizForm(
readQuizValues(formData),
await fetchSchoolGradeCodes()
);
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
await updateQuiz(quizSn, validation.values);
return null;
});
if (failed) {
return failed;
}
revalidatePath(contentListPath('quiz'));
redirect(contentListPath('quiz'));
}
export async function deleteContentAction(
kind: ContentKind,
id: string
): Promise<ContentFormState> {
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) {
return failure(error, DELETE_FAILED_MESSAGE);
}
revalidatePath(contentListPath(kind));
return { status: 'success' };
}