'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 { 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 ): Promise { 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 { 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 { 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:` | `new`)과 `imageFile`(같은 순서, 안 고른 칸은 빈 파일). */ async function resolveImageBundleId(formData: FormData): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 : '')) .filter((value): value is MerchantProductBadge => MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === value) ); } async function resolveProductImageId(formData: FormData): Promise { 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 { 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 { 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 { 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' }; }