fix(admin): 견적 재발송 방어 + title 타입 검증
- POST /api/admin/quotes: title을 typeof + trim() 검증으로 falsy 문자열 방어 - POST /api/admin/quotes/[id]/send: sent/accepted/rejected 상태면 200 조기 반환(alreadySent: true)으로 중복 발송 차단 - 견적 편집 UI: isSentStatus 플래그로 발송 버튼 비활성화·라벨 "발송됨" 표시, alreadySent 응답 시 안내 alert 처리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -127,14 +127,21 @@ export default function QuoteEditorPage() {
|
||||
}
|
||||
|
||||
// ── 고객에게 발송 ───────────────────────
|
||||
const SENT_STATUSES = ['sent', 'accepted', 'rejected'];
|
||||
const isSentStatus = SENT_STATUSES.includes(form.status);
|
||||
|
||||
async function sendToClient() {
|
||||
if (!form.client_email) return;
|
||||
if (!form.client_email || isSentStatus) return;
|
||||
if (!confirm("고객에게 견적 메일을 발송하고 상태를 '발송됨'으로 변경합니다.")) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/quotes/${id}/send`, { method: 'POST' });
|
||||
const d = await res.json();
|
||||
if (res.ok && d.success) {
|
||||
if (d.alreadySent) {
|
||||
alert('이미 발송된 견적입니다');
|
||||
return;
|
||||
}
|
||||
setField('status', 'sent');
|
||||
if (d.emailSent === false) {
|
||||
alert('상태는 변경됐으나 메일 발송에 실패했습니다 — 수동 발송이 필요합니다');
|
||||
@@ -285,17 +292,21 @@ export default function QuoteEditorPage() {
|
||||
{/* 고객에게 발송 */}
|
||||
<button
|
||||
onClick={sendToClient}
|
||||
disabled={sending || !form.client_email}
|
||||
title={!form.client_email ? '고객 이메일을 먼저 입력하세요' : '고객에게 견적 메일 발송'}
|
||||
disabled={sending || !form.client_email || isSentStatus}
|
||||
title={
|
||||
isSentStatus ? '이미 발송된 견적입니다' :
|
||||
!form.client_email ? '고객 이메일을 먼저 입력하세요' :
|
||||
'고객에게 견적 메일 발송'
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{sending ? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
고객에게 발송
|
||||
{isSentStatus ? '발송됨' : '고객에게 발송'}
|
||||
</button>
|
||||
{!form.client_email && (
|
||||
{!form.client_email && !isSentStatus && (
|
||||
<span className="text-xs text-amber-400/80">이메일 입력 필요</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -31,16 +31,21 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
|
||||
return NextResponse.json({ error: '견적서를 찾을 수 없습니다' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 2. 고객 이메일 필수
|
||||
// 2. 이미 발송/수락/거절된 견적은 재발송 차단
|
||||
if (['sent', 'accepted', 'rejected'].includes(quote.status)) {
|
||||
return NextResponse.json({ success: true, emailSent: false, alreadySent: true });
|
||||
}
|
||||
|
||||
// 3. 고객 이메일 필수
|
||||
if (!quote.client_email) {
|
||||
return NextResponse.json({ error: '고객 이메일을 먼저 입력하세요' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 3. public_token 보장
|
||||
// 4. public_token 보장
|
||||
const quoteToken: string = quote.public_token || crypto.randomUUID();
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// 4. 견적 상태 업데이트
|
||||
// 5. 견적 상태 업데이트
|
||||
const updatePayload: Record<string, unknown> = { status: 'sent', updated_at: nowIso };
|
||||
if (!quote.public_token) updatePayload.public_token = quoteToken;
|
||||
|
||||
@@ -54,7 +59,7 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
|
||||
return NextResponse.json({ error: '견적 상태 업데이트 실패' }, { status: 500 });
|
||||
}
|
||||
|
||||
// 5. 연결된 의뢰 상태 동기화 (실패해도 진행)
|
||||
// 6. 연결된 의뢰 상태 동기화 (실패해도 진행)
|
||||
if (quote.contact_request_id) {
|
||||
const { error: syncError } = await supabase
|
||||
.from('contact_requests')
|
||||
@@ -65,7 +70,7 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 견적 메일 발송 (실패해도 상태 변경은 유지)
|
||||
// 7. 견적 메일 발송 (실패해도 상태 변경은 유지)
|
||||
let emailSent = true;
|
||||
try {
|
||||
await sendQuoteSentEmail({
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// 의뢰(contact_requests) 연결용 필드 — string만 허용
|
||||
const insertData: Record<string, unknown> = {
|
||||
title: body.title || '새 견적서',
|
||||
title: typeof body.title === 'string' && body.title.trim() ? body.title : '새 견적서',
|
||||
client_name: typeof body.client_name === 'string' ? body.client_name : '',
|
||||
client_email: typeof body.client_email === 'string' ? body.client_email : '',
|
||||
valid_until: body.valid_until || null,
|
||||
|
||||
Reference in New Issue
Block a user