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
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 { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
import {
buildFileImageUrl,
uploadAttachment,
uploadAttachments,
} from '@/lib/data/repositories/file-repository';
import {
createMerchantDetail,
deleteMerchantDetail,
fetchMerchantDetail,
updateMerchantDetail,
} from '@/lib/data/repositories/merchant-detail-repository';
import {
createMerchantProduct,
deleteMerchantProduct,
fetchMerchantProduct,
updateMerchantProduct,
} from '@/lib/data/repositories/merchant-product-repository';
import { CODE_GROUP } from '@/lib/domain/common-code';
import {
FILE_MODULE,
MAX_ATTACHMENT_BYTES,
allowedExtensions,
isAllowedFileExtension,
} from '@/lib/domain/file-module';
import { MAX_EXTRA_IMAGES, MAX_IMAGE_BYTES } from '@/lib/domain/merchant-detail';
import {
validateMerchantDetail,
type MerchantDetailFormState,
type MerchantDetailValues,
} from '@/lib/domain/merchant-detail-form';
import {
MERCHANT_DETAILS_PATH,
merchantDetailPath,
} from '@/lib/domain/merchant-detail-query';
import type { MerchantProductBadge } from '@/lib/domain/merchant-product';
import {
validateMerchantProduct,
type MerchantProductFormState,
} from '@/lib/domain/merchant-product-form';
/**
* 가맹몰 상세 Server Action — 상세(가맹점당 1건)와 메뉴(여러 건) 둘 다 여기서 다룬다.
*
* 노출 토글은 단건을 읽어 전 컬럼을 그대로 다시 보낸다 — 백엔드 `PUT`이 모든 컬럼을 덮어쓰기 때문이다.
* 단건이 대표이미지·카테고리를 안 주는 응답이면(구 버전) 값이 지워지므로 토글을 거부한다.
*/
const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
const DETAIL_NOT_LOADED_MESSAGE =
'백엔드가 상세 값을 돌려주지 않아 목록에서는 바꿀 수 없습니다. 수정 화면에서 바꿔 주세요.';
// 백엔드가 삭제된 상세도 "있는 것"으로 보는 결함(보고함) — 409는 대개 이 경우다.
const DETAIL_CONFLICT_MESSAGE =
'이 가맹점에는 이미 상세가 있습니다. 삭제했던 상세라면 백엔드에서 정리해야 다시 등록할 수 있습니다.';
const IMAGE_OVERSIZE_MESSAGE = `이미지는 장당 ${Math.floor(MAX_IMAGE_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`;
const IMAGES_TOO_MANY_MESSAGE = `추가 이미지는 최대 ${MAX_EXTRA_IMAGES}장까지입니다.`;
const REQUEST_OVERSIZE_MESSAGE = `한 번에 올리는 이미지의 합이 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB를 넘습니다. 나눠서 저장해 주세요.`;
class InputError extends Error {
constructor(message: string) {
super(message);
this.name = 'InputError';
}
}
export type MerchantDetailActionResult =
| { status: 'success' }
| { status: 'error'; message: string };
function readString(formData: FormData, key: string): string {
const value = formData.get(key);
return typeof value === 'string' ? value : '';
}
function readOptionalInteger(formData: FormData, key: string): number | null {
const raw = readString(formData, key).trim();
if (raw === '') return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function readFiles(formData: FormData, key: string): Array<File | null> {
return formData
.getAll(key)
.map((entry) => (entry instanceof File && entry.size > 0 ? entry : null));
}
function assertImage(file: File): void {
if (file.size > MAX_IMAGE_BYTES) {
throw new InputError(IMAGE_OVERSIZE_MESSAGE);
}
if (!isAllowedFileExtension(FILE_MODULE.merchantImage, file.name)) {
throw new InputError(
`이미지는 ${allowedExtensions(FILE_MODULE.merchantImage).join(', ')} 파일만 올릴 수 있습니다.`
);
}
}
function failureMessage(error: unknown, fallback: string): string {
const showable = error instanceof InputError || error instanceof BackendRequestError;
return showable && error.message ? error.message : fallback;
}
type BatchFailure = { failedCount: number; error: unknown };
/**
* 여러 건을 동시에 보내고 실패 수와 첫 오류를 돌려준다. 백엔드 관리자 API에는 트랜잭션이 없어
* 한 건이 실패해도 나머지는 그대로 반영된다 — 차례로 보내며 멈추는 것보다 동시에 보내고 결과를
* 세어 알리는 편이 빠르고 정확하다.
*/
async function runForEach(
targets: readonly string[],
run: (frcsNo: string) => Promise<void>
): Promise<BatchFailure | null> {
const results = await Promise.allSettled(targets.map((frcsNo) => run(frcsNo)));
const rejected = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (rejected.length === 0) return null;
unstable_rethrow(rejected[0].reason);
return { failedCount: rejected.length, error: rejected[0].reason };
}
function batchFailureMessage(failure: BatchFailure, total: number, fallback: string): string {
const reason = failureMessage(failure.error, fallback);
return total > 1 ? `${total}건 중 ${failure.failedCount}건 실패 — ${reason}` : reason;
}
// ── 상세 ──────────────────────────────────────────────────────────────────────
async function resolveThumbnailId(formData: FormData): Promise<string> {
const file = readFiles(formData, 'thumbnailFile')[0] ?? null;
if (!file) {
return readString(formData, 'thumbnailFileId');
}
assertImage(file);
return uploadAttachment(file, FILE_MODULE.merchantImage.id);
}
/** 저장된 이미지 한 장을 다시 파일로 — 묶음을 새 순서로 다시 올릴 때 쓴다. */
async function fetchSavedImage(fileId: string, fileSn: number): Promise<File> {
const url = buildFileImageUrl(fileId, fileSn);
if (!url) {
throw new InputError(INVALID_REQUEST_MESSAGE);
}
const response = await fetch(url, { cache: 'no-store' });
const blob = response.ok ? await response.blob() : null;
// 파일이 없어도 백엔드가 200에 빈 본문으로 답한다(보고함) — 빈 파일을 다시 올리면 500이 난다.
if (!blob || blob.size === 0) {
throw new InputError(
`저장된 추가 이미지 ${fileSn}번을 다시 읽지 못했습니다. 이 서버에 파일이 없을 수 있으니 이미지를 다시 올려 주세요.`
);
}
const extension = blob.type.split('/')[1] || 'jpg';
return new File([blob], `image-${fileSn}.${extension}`, { type: blob.type });
}
/**
* 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번한다.
* - 저장된 장이 그대로 앞에 있고 새 장이 뒤에만 붙었으면 기존 묶음에 덧붙인다(백엔드 40f85c4) —
* 저장된 장을 다시 읽지 않아 빠르고, 파일이 다른 서버에 있어도 된다.
* - 순서를 바꾸거나 저장된 장을 지웠으면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽는다.
*
* 폼: 칸마다 `imageSlot`(`saved:<fileSn>` | `new`)과 `imageFile`(같은 순서, 안 고른 칸은 빈 파일).
*/
async function resolveImageBundleId(formData: FormData): Promise<string> {
const retainedId = readString(formData, 'imageFileId');
const slots = formData
.getAll('imageSlot')
.map((entry) => (typeof entry === 'string' ? entry : ''));
const files = readFiles(formData, 'imageFile');
if (slots.length > MAX_EXTRA_IMAGES) {
throw new InputError(IMAGES_TOO_MANY_MESSAGE);
}
const savedSns = slots
.map((slot, index) =>
files[index] === null && slot.startsWith('saved:') ? Number(slot.slice(6)) : null
)
.filter((sn): sn is number => sn !== null);
const hasNewFile = files.some((file) => file !== null);
const untouched =
!hasNewFile &&
savedSns.length === slots.length &&
savedSns.every((sn, index) => sn === index + 1);
if (slots.length === 0) {
return '';
}
if (untouched) {
return retainedId;
}
const appendOnly =
retainedId !== '' &&
savedSns.length > 0 &&
savedSns.every((sn, index) => sn === index + 1) &&
slots.every((slot, index) =>
index < savedSns.length ? files[index] === null && slot === `saved:${index + 1}` : files[index] !== null
);
if (appendOnly) {
const appended = files.slice(savedSns.length).filter((file): file is File => file !== null);
appended.forEach(assertImage);
const appendedBytes = appended.reduce((total, file) => total + file.size, 0);
if (appendedBytes > MAX_ATTACHMENT_BYTES) {
throw new InputError(REQUEST_OVERSIZE_MESSAGE);
}
return uploadAttachments(appended, FILE_MODULE.merchantImage.id, retainedId);
}
let newBytes = 0;
const bundle: File[] = [];
for (const [index, slot] of slots.entries()) {
const picked = files[index];
if (picked) {
assertImage(picked);
newBytes += picked.size;
bundle.push(picked);
} else if (slot.startsWith('saved:') && retainedId) {
bundle.push(await fetchSavedImage(retainedId, Number(slot.slice(6))));
}
}
if (newBytes > MAX_ATTACHMENT_BYTES) {
throw new InputError(REQUEST_OVERSIZE_MESSAGE);
}
if (bundle.length === 0) {
return '';
}
return uploadAttachments(bundle, FILE_MODULE.merchantImage.id);
}
function readDetailValues(
formData: FormData,
thumbnailFileId: string,
imageFileId: string
): MerchantDetailValues {
return {
frcsNo: readString(formData, 'frcsNo'),
categoryCode: readString(formData, 'categoryCode'),
description: readString(formData, 'description'),
openTime: readString(formData, 'openTime'),
closeTime: readString(formData, 'closeTime'),
closedDays: readString(formData, 'closedDays'),
isVisible: readString(formData, 'isVisible') !== 'N',
isVoucherUsable: readString(formData, 'isVoucherUsable') !== 'N',
sortOrder: readOptionalInteger(formData, 'sortOrder'),
thumbnailFileId,
imageFileId,
};
}
export async function saveMerchantDetailAction(
_prevState: MerchantDetailFormState,
formData: FormData
): Promise<MerchantDetailFormState> {
await verifySession();
const frcsNo = readString(formData, 'frcsNo');
const isEdit = readString(formData, 'mode') === 'edit';
if (!frcsNo) {
return { status: 'error', errors: { frcsNo: '업체를 선택해 주세요.' } };
}
let failed: MerchantDetailFormState | null = null;
try {
const [thumbnailFileId, imageFileId, categories] = await Promise.all([
resolveThumbnailId(formData),
resolveImageBundleId(formData),
fetchCommonCodes(CODE_GROUP.merchantType),
]);
const validation = validateMerchantDetail(
readDetailValues(formData, thumbnailFileId, imageFileId),
categories
);
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
if (isEdit) {
await updateMerchantDetail(validation.values);
} else {
await createMerchantDetail(validation.values);
}
} catch (error) {
unstable_rethrow(error);
console.error('[merchant-details] 저장 실패', error);
const isConflict = !isEdit && error instanceof BackendRequestError && error.code === 409;
failed = {
status: 'error',
message: isConflict ? DETAIL_CONFLICT_MESSAGE : failureMessage(error, SAVE_FAILED_MESSAGE),
};
}
if (failed) {
return failed;
}
revalidatePath(MERCHANT_DETAILS_PATH);
revalidatePath(merchantDetailPath(frcsNo));
// 등록·수정이 한 화면이라 여기서 옮기지 않는다 — 화면이 결과를 보고 머물거나 주소만 바꾼다.
return { status: 'success', frcsNo, created: !isEdit };
}
export async function deleteMerchantDetailAction(
frcsNo: string
): Promise<MerchantDetailActionResult> {
await verifySession();
if (!frcsNo) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
try {
await deleteMerchantDetail(frcsNo);
} catch (error) {
unstable_rethrow(error);
console.error('[merchant-details] 삭제 실패', error);
return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
}
revalidatePath(MERCHANT_DETAILS_PATH);
return { status: 'success' };
}
export async function deleteMerchantDetailsAction(
frcsNos: string[]
): Promise<MerchantDetailActionResult> {
await verifySession();
const targets = frcsNos.filter((value) => typeof value === 'string' && value !== '');
if (targets.length === 0) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
const failure = await runForEach(targets, deleteMerchantDetail);
revalidatePath(MERCHANT_DETAILS_PATH);
if (failure) {
console.error('[merchant-details] 일괄 삭제 실패', failure.error);
return { status: 'error', message: batchFailureMessage(failure, targets.length, DELETE_FAILED_MESSAGE) };
}
return { status: 'success' };
}
async function toggleDetailVisibility(frcsNo: string, isVisible: boolean): Promise<void> {
const detail = await fetchMerchantDetail(frcsNo, { withImages: false });
// 대표이미지는 필수라 상세가 있으면 반드시 온다 — 없으면 백엔드가 상세를 돌려주지 않은 것이다.
if (!detail || !detail.hasDetail || !detail.thumbnailFileId || !detail.categoryCode) {
throw new InputError(DETAIL_NOT_LOADED_MESSAGE);
}
await updateMerchantDetail({
frcsNo,
categoryCode: detail.categoryCode,
description: detail.description ?? '',
openTime: detail.openTime ?? '',
closeTime: detail.closeTime ?? '',
closedDays: detail.closedDays ?? '',
isVisible,
isVoucherUsable: true,
sortOrder: detail.sortOrder,
thumbnailFileId: detail.thumbnailFileId,
imageFileId: detail.imageFileId ?? '',
});
}
export async function updateMerchantDetailVisibilityAction(
frcsNo: string,
isVisible: boolean
): Promise<MerchantDetailActionResult> {
await verifySession();
if (!frcsNo) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
try {
await toggleDetailVisibility(frcsNo, isVisible);
} catch (error) {
unstable_rethrow(error);
console.error('[merchant-details] 노출 변경 실패', error);
return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
}
revalidatePath(MERCHANT_DETAILS_PATH);
revalidatePath(merchantDetailPath(frcsNo));
return { status: 'success' };
}
export async function deactivateMerchantDetailsAction(
frcsNos: string[]
): Promise<MerchantDetailActionResult> {
await verifySession();
const targets = frcsNos.filter((value) => typeof value === 'string' && value !== '');
if (targets.length === 0) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
const failure = await runForEach(targets, (frcsNo) => toggleDetailVisibility(frcsNo, false));
revalidatePath(MERCHANT_DETAILS_PATH);
if (failure) {
console.error('[merchant-details] 일괄 비활성 실패', failure.error);
return { status: 'error', message: batchFailureMessage(failure, targets.length, SAVE_FAILED_MESSAGE) };
}
return { status: 'success' };
}
// ── 메뉴 ──────────────────────────────────────────────────────────────────────
function readBadges(formData: FormData): MerchantProductBadge[] {
return formData
.getAll('badges')
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((value) => value !== '');
}
async function resolveProductImageId(formData: FormData): Promise<string> {
const file = readFiles(formData, 'imageFile')[0] ?? null;
if (!file) {
return readString(formData, 'imageFileId');
}
assertImage(file);
return uploadAttachment(file, FILE_MODULE.merchantImage.id);
}
export async function saveMerchantProductAction(
_prevState: MerchantProductFormState,
formData: FormData
): Promise<MerchantProductFormState> {
await verifySession();
const frcsNo = readString(formData, 'frcsNo');
const productSn = readString(formData, 'productSn');
if (!frcsNo) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
let failed: MerchantProductFormState | null = null;
try {
const [imageFileId, badgeCodes] = await Promise.all([
resolveProductImageId(formData),
fetchCommonCodes(CODE_GROUP.merchantProductBadge),
]);
const validation = validateMerchantProduct(
{
frcsNo,
name: readString(formData, 'name'),
price: readOptionalInteger(formData, 'price'),
description: readString(formData, 'description'),
badges: readBadges(formData),
isVisible: readString(formData, 'isVisible') !== 'N',
sortOrder: readOptionalInteger(formData, 'sortOrder'),
imageFileId,
},
badgeCodes
);
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
if (productSn) {
await updateMerchantProduct(productSn, validation.values);
} else {
await createMerchantProduct(validation.values);
}
} catch (error) {
unstable_rethrow(error);
console.error('[merchant-products] 저장 실패', error);
failed = { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
}
if (failed) {
return failed;
}
revalidatePath(merchantDetailPath(frcsNo));
redirect(merchantDetailPath(frcsNo));
}
export async function deleteMerchantProductAction(
frcsNo: string,
productSn: string
): Promise<MerchantDetailActionResult> {
await verifySession();
if (!frcsNo || !productSn) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
try {
await deleteMerchantProduct(productSn);
} catch (error) {
unstable_rethrow(error);
console.error('[merchant-products] 삭제 실패', error);
return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
}
revalidatePath(merchantDetailPath(frcsNo));
return { status: 'success' };
}
export async function updateMerchantProductVisibilityAction(
frcsNo: string,
productSn: string,
isVisible: boolean
): Promise<MerchantDetailActionResult> {
await verifySession();
if (!frcsNo || !productSn) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
try {
const product = await fetchMerchantProduct(productSn);
if (!product) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
await updateMerchantProduct(productSn, {
frcsNo: product.frcsNo,
name: product.name,
price: product.price,
description: product.description ?? '',
badges: product.badges,
isVisible,
sortOrder: product.sortOrder,
imageFileId: product.imageFileId ?? '',
});
} catch (error) {
unstable_rethrow(error);
console.error('[merchant-products] 노출 변경 실패', error);
return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
}
revalidatePath(merchantDetailPath(frcsNo));
return { status: 'success' };
}