From 41f6b347a96b53f7a655f10a809d4aa1d9f8b836 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 16 May 2026 05:33:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(api):=20/api/survey=20POST=20=E2=80=94=20D?= =?UTF-8?q?B=20=EC=A0=80=EC=9E=A5=20+=20Resend=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rate limit: IP당 1분 5회 (기존 contact 패턴) - 필수 validation: age_range, status, awareness_freq - 입력 정제(sanitizeStr) + 이메일 형식 검증 - supabase INSERT (service role, RLS 우회) - 이메일 입력 시: Resend 즉시 확인 메일 + email_confirmation_sent 마킹 - 메일 실패는 응답 저장 성공에 영향 X Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/survey/route.ts | 102 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 app/api/survey/route.ts diff --git a/app/api/survey/route.ts b/app/api/survey/route.ts new file mode 100644 index 0000000..07210b1 --- /dev/null +++ b/app/api/survey/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from 'next/server'; +import { Resend } from 'resend'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { isValidEmail, sanitizeStr, checkRateLimit, getClientIp, INPUT_LIMITS } from '@/lib/security'; + +export const runtime = 'nodejs'; + +const resend = new Resend(process.env.RESEND_API_KEY); + +export async function POST(request: Request) { + try { + // Rate Limit: IP당 1분 5회 + const ip = getClientIp(request); + const rl = checkRateLimit(`survey:${ip}`, 60_000, 5); + if (!rl.allowed) { + return NextResponse.json( + { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' }, + { + status: 429, + headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) }, + } + ); + } + + const body = await request.json(); + + // 기본 validation — Q1, Q2는 필수 + if (!body.age_range || !body.status || !body.awareness_freq) { + return NextResponse.json( + { error: '필수 응답이 누락되었습니다.' }, + { status: 400 } + ); + } + + // 입력 정제 + const tools_other = body.tools_other ? sanitizeStr(body.tools_other, 200) : null; + const free_opinion = body.free_opinion ? sanitizeStr(body.free_opinion, 2000) : null; + const email = body.email ? sanitizeStr(body.email, INPUT_LIMITS.EMAIL) : null; + if (email && !isValidEmail(email)) { + return NextResponse.json( + { error: '올바른 이메일 형식이 아닙니다.' }, + { status: 400 } + ); + } + + // DB INSERT (service role — RLS 우회) + const supabase = createAdminClient(); + const { data, error } = await supabase + .from('survey_responses') + .insert({ + age_range: body.age_range, + status: body.status, + awareness_freq: body.awareness_freq, + tools_used: Array.isArray(body.tools_used) ? body.tools_used : null, + tools_other, + cost_range: body.cost_range ?? null, + best_tool: body.best_tool ?? null, + best_satisfy: typeof body.best_satisfy === 'number' ? body.best_satisfy : null, + free_opinion, + email, + user_agent: body.user_agent ? sanitizeStr(body.user_agent, 500) : null, + referrer: body.referrer ? sanitizeStr(body.referrer, 500) : null, + utm_source: body.utm_source ? sanitizeStr(body.utm_source, 100) : null, + utm_medium: body.utm_medium ? sanitizeStr(body.utm_medium, 100) : null, + utm_campaign: body.utm_campaign ? sanitizeStr(body.utm_campaign, 100) : null, + completion_seconds: typeof body.completion_seconds === 'number' ? body.completion_seconds : null, + }) + .select() + .single(); + + if (error) { + console.error('[Survey] DB insert error:', error); + return NextResponse.json({ error: '저장에 실패했습니다.' }, { status: 500 }); + } + + // Resend 즉시 확인 메일 (이메일 입력 시만) + if (email) { + try { + await resend.emails.send({ + from: '쟁승메이드 ', + to: email, + subject: 'CONTOUR 설문 참여 감사드립니다', + html: `

안녕하세요,

+

설문에 참여해주셔서 감사합니다. 결과는 추후 공유드리겠습니다.

+

— 쟁승메이드

`, + }); + await supabase + .from('survey_responses') + .update({ email_confirmation_sent: true }) + .eq('id', data.id); + } catch (mailErr) { + console.error('[Survey] Resend error:', mailErr); + // 메일 실패는 응답 저장 성공에 영향 X + } + } + + return NextResponse.json({ ok: true, id: data.id }); + } catch (e) { + console.error('[Survey] Unexpected error:', e); + return NextResponse.json({ error: '제출 처리 중 오류가 발생했습니다.' }, { status: 500 }); + } +}