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 {
MERCHANT_PRODUCT_BADGE_OPTIONS,
type MerchantProductBadge,
} from '@/lib/domain/merchant-product';
import {
validateMerchantProduct,
type MerchantProductFormState,
} from '@/lib/domain/merchant-product-form';
/**
* 가맹몰 상세 Server Action — 상세(가맹점당 1건)와 메뉴(여러 건) 둘 다 여기서 다룬다.
*
* 이슈: 목록의 [비활성]/[노출]은 백엔드가 상세 전체를 돌려줘야 안전하다. `PUT`이 모든 컬럼을
* 무조건 덮어써서, 단건 조회가 비어 있는 지금 값 없이 보내면 소개·영업시간·이미지가 지워진다.
* 그래서 조회 결과에 대표이미지가 없으면 목록 토글을 거부하고 수정 화면으로 안내한다.
* 단건 SELECT가 보강되면(보고함) 그대로 동작한다.
*/
const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
const DETAIL_NOT_LOADED_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;
}
// ── 상세 ──────────────────────────────────────────────────────────────────────
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' });
if (!response.ok) {
throw new InputError('저장된 이미지를 다시 읽지 못했습니다. 잠시 후 다시 시도해 주세요.');
}
const blob = await response.blob();
const extension = blob.type.split('/')[1] || 'jpg';
return new File([blob], `image-${fileSn}.${extension}`, { type: blob.type });
}
/**
* 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번하므로 순서·삭제·추가
* 어느 것이든 바뀌면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽어 합친다.
*
* 폼: 칸마다 `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;
}
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);
failed = { status: 'error', 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 };
}
try {
for (const frcsNo of targets) {
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' };
}
async function toggleDetailVisibility(frcsNo: string, isVisible: boolean): Promise<void> {
const detail = await fetchMerchantDetail(frcsNo);
// 대표이미지는 필수라 상세가 있으면 반드시 온다 — 없으면 백엔드가 상세를 돌려주지 않은 것이다.
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 };
}
try {
for (const frcsNo of targets) {
await toggleDetailVisibility(frcsNo, false);
}
} catch (error) {
unstable_rethrow(error);
console.error('[merchant-details] 일괄 비활성 실패', error);
return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
}
revalidatePath(MERCHANT_DETAILS_PATH);
return { status: 'success' };
}
// ── 메뉴 ──────────────────────────────────────────────────────────────────────
function readBadges(formData: FormData): MerchantProductBadge[] {
return formData
.getAll('badges')
.map((entry) => (typeof entry === 'string' ? entry : ''))
.filter((value): value is MerchantProductBadge =>
MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.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 = await resolveProductImageId(formData);
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,
});
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' };
}