File name
Commit message
Commit date
File name
Commit message
Commit date
09-04
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, 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<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) {
// 이슈: 세션이 밀렸을 때 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<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 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<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 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<ContentFormState> {
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<ContentFormState> {
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<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) {
unstable_rethrow(error);
return failure(error, DELETE_FAILED_MESSAGE);
}
revalidatePath(contentListPath(kind));
return { status: 'success' };
}