File name
Commit message
Commit date
File name
Commit message
Commit date
09-16
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', message: INVALID_REQUEST_MESSAGE };
}
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' };
}