Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions docs/consultation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). 공개 사이트는 이 값을 넘기지 않는다.
Expand All @@ -126,4 +134,4 @@

## 아직 없는 것

메일 알림 · 보관 기간에 따른 자동 삭제 · 스팸 표시 · 신청자 자동 답장. 필요해지는 순서대로 붙인다.
카카오 알림톡(채널 개설·템플릿 심사 후) · 보관 기간에 따른 자동 삭제 · 스팸 표시 · 신청자 자동 답장. 필요해지는 순서대로 붙인다.
5 changes: 5 additions & 0 deletions src/lib/actions/consultation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -100,5 +101,9 @@ export async function submitConsultation(
return fail("접수 중 문제가 생겼습니다. 잠시 후 다시 시도해 주세요.");
}

// 저장이 끝난 뒤에만 알린다. 발송 실패는 안에서 삼키므로 접수 결과에 영향이 없다.
// 서버리스에서 응답 후 백그라운드 실행은 보장되지 않으므로 await 한다.
await notifyNewConsultation();

return { status: "ok", message: null, errors: {} };
}
56 changes: 56 additions & 0 deletions src/lib/consultation-notify.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
Loading