From 6d24b14376e3ea26c5bb007b98a0b901b71edf38 Mon Sep 17 00:00:00 2001 From: kojoo112 Date: Thu, 1 Oct 2026 11:34:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(consultation):=20=EC=83=88=20=EC=83=81?= =?UTF-8?q?=EB=8B=B4=20=EC=A0=91=EC=88=98=EB=A5=BC=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=EC=95=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resend REST API 를 fetch 로 호출한다. 의존성 추가 없음. - RESEND_API_KEY / CONSULTATION_NOTIFY_TO / CONSULTATION_NOTIFY_FROM 셋이 모두 있어야 켜지고, 없으면 접수만 되고 알림은 생략된다 - 메일에는 상담 내용(answers)을 넣지 않는다 — 접수 사실·시각·접수함 링크만. 외부 메일 로그에 사건 내용이 남지 않게 한다 - 발송 실패는 삼킨다. 저장 성공 뒤의 일이라 접수 결과에 영향 없음 Co-Authored-By: Claude Fable 5 --- .env.example | 11 +++++++ docs/consultation.md | 12 +++++-- src/lib/actions/consultation.ts | 5 +++ src/lib/consultation-notify.ts | 56 +++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/lib/consultation-notify.ts diff --git a/.env.example b/.env.example index 85d404b..a91b98e 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,17 @@ SUPABASE_SERVICE_ROLE_KEY= # 카카오맵 JavaScript 키 (소개 페이지 지도) NEXT_PUBLIC_KAKAO_MAP_KEY= +# ───── 선택: 새 상담 접수 이메일 알림 (셋 다 있어야 켜진다, 없으면 접수만 되고 알림 생략) ───── + +# Resend API 키 (https://resend.com). 서버 전용. +RESEND_API_KEY= + +# 알림을 받을 주소 (변호사 수신함) +CONSULTATION_NOTIFY_TO= + +# 발신 주소. Resend 에 인증된 도메인이어야 한다 (테스트는 onboarding@resend.dev 가능) +CONSULTATION_NOTIFY_FROM= + # 사이트 기본 URL (메타태그·sitemap 생성용) NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/docs/consultation.md b/docs/consultation.md index a41605c..f29b003 100644 --- a/docs/consultation.md +++ b/docs/consultation.md @@ -111,7 +111,15 @@ - 검색어는 `escapeLike` 로 `%` `_` 를 막고 100자에서 자른다. - 수천 건을 넘어 느려지면 `pg_trgm` 인덱스 한 줄을 얹는다. 그 전엔 인덱스 없음. -### 9. 접수 완료는 그 자리에서 +### 9. 메일 알림은 사실만, 내용은 넣지 않는다 + +접수가 저장되면 변호사에게 이메일로 알린다 (`src/lib/consultation-notify.ts`, Resend). + +- **메일에 `answers` 를 넣지 않는다.** 접수 사실 · 시각 · 접수함 링크만 보낸다. 외부 메일 서비스의 로그와 수신함에 사건 내용이 남지 않게 하기 위해서다 (6번 원칙의 연장). +- `RESEND_API_KEY` · `CONSULTATION_NOTIFY_TO` · `CONSULTATION_NOTIFY_FROM` 셋이 모두 있어야 켜진다. 하나라도 없으면 접수만 되고 알림은 조용히 생략된다. +- 발송 실패는 함수 안에서 삼킨다. 저장이 끝난 뒤의 일이므로 신청자에게 에러를 보여주지 않는다. + +### 10. 접수 완료는 그 자리에서 화면 이동 없이 가운데 작은 대화창. 제목 · 안내 한 줄 · 전화 버튼은 어드민 상담 블록의 칸(`success_message` · `success_detail` · `success_phone`). 어드민에서 그 칸에 커서를 두면 미리보기에 대화창이 뜬다(`previewField` → `previewSuccess`). 공개 사이트는 이 값을 넘기지 않는다. @@ -126,4 +134,4 @@ ## 아직 없는 것 -메일 알림 · 보관 기간에 따른 자동 삭제 · 스팸 표시 · 신청자 자동 답장. 필요해지는 순서대로 붙인다. +카카오 알림톡(채널 개설·템플릿 심사 후) · 보관 기간에 따른 자동 삭제 · 스팸 표시 · 신청자 자동 답장. 필요해지는 순서대로 붙인다. diff --git a/src/lib/actions/consultation.ts b/src/lib/actions/consultation.ts index b396567..842024b 100644 --- a/src/lib/actions/consultation.ts +++ b/src/lib/actions/consultation.ts @@ -8,6 +8,7 @@ import { validateAnswers, type ConsultationState, } from "@/lib/consultation"; +import { notifyNewConsultation } from "@/lib/consultation-notify"; import { createAdminClient } from "@/lib/supabase/admin"; import { createPublicClient } from "@/lib/supabase/public"; import type { PageSectionItem } from "@/lib/sections"; @@ -100,5 +101,9 @@ export async function submitConsultation( return fail("접수 중 문제가 생겼습니다. 잠시 후 다시 시도해 주세요."); } + // 저장이 끝난 뒤에만 알린다. 발송 실패는 안에서 삼키므로 접수 결과에 영향이 없다. + // 서버리스에서 응답 후 백그라운드 실행은 보장되지 않으므로 await 한다. + await notifyNewConsultation(); + return { status: "ok", message: null, errors: {} }; } diff --git a/src/lib/consultation-notify.ts b/src/lib/consultation-notify.ts new file mode 100644 index 0000000..2bb168c --- /dev/null +++ b/src/lib/consultation-notify.ts @@ -0,0 +1,56 @@ +// 새 상담 접수를 변호사에게 이메일로 알린다. Resend REST API 를 fetch 로 호출한다. +// 서버 전용: RESEND_API_KEY 를 읽는다. 클라이언트 컴포넌트에서 import 하지 않는다. +// +// 개인정보: 메일에는 상담 내용(answers)을 넣지 않는다. 접수 사실과 어드민 링크만 보낸다. +// 외부 메일 서비스의 로그·수신함에 사건 내용이 남지 않게 하기 위해서다 (docs/consultation.md). + +const RESEND_ENDPOINT = "https://api.resend.com/emails"; + +/** + * 알림 발송. 실패해도 던지지 않는다 — 접수 자체는 이미 저장된 뒤라서, + * 알림이 죽었다고 신청자에게 에러를 보여줄 이유가 없다. + * + * RESEND_API_KEY / CONSULTATION_NOTIFY_TO / CONSULTATION_NOTIFY_FROM 중 + * 하나라도 없으면 알림 기능이 꺼진 것으로 보고 조용히 넘어간다. + */ +export async function notifyNewConsultation(): Promise { + const apiKey = process.env.RESEND_API_KEY; + const to = process.env.CONSULTATION_NOTIFY_TO; + const from = process.env.CONSULTATION_NOTIFY_FROM; + if (!apiKey || !to || !from) return; + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; + const inboxUrl = `${siteUrl.replace(/\/$/, "")}/admin/consultations`; + const receivedAt = new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Asia/Seoul", + }).format(new Date()); + + try { + const res = await fetch(RESEND_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from, + to: [to], + subject: "새 상담 신청이 접수되었습니다", + text: [ + `새 상담 신청이 접수되었습니다. (${receivedAt})`, + "", + `접수함에서 확인: ${inboxUrl}`, + "", + "개인정보 보호를 위해 상담 내용은 메일에 담지 않습니다.", + ].join("\n"), + }), + }); + if (!res.ok) { + console.error("상담 알림 메일 발송 실패", res.status, await res.text()); + } + } catch (e) { + console.error("상담 알림 메일 발송 실패", e instanceof Error ? e.message : e); + } +}